Files
my-pi/pi-permission-system/docs/plans/archive/0041-extract-permission-gate.md
T

9.6 KiB
Raw Blame History

issue, issue_title
issue issue_title
41 Extract a reusable permission-gate function to eliminate repeated deny/ask/allow branching

Extract a reusable permission-gate function

Problem Statement

src/index.ts contains five near-identical deny/ask/allow branching blocks (skill input, skill-read path, external-directory for file tools, external-directory for bash, and normal tool permission). Each block repeats the same three-branch structure: deny → log + block, ask → check UI availability → prompt → maybe block, allow → fall through. The only variation is the log context, message formatters, and return shape. This duplication inflates the tool_call handler by ~170 lines and makes every future permission-surface addition copy-paste-prone.

Goals

  • Extract a single applyPermissionGate() function into src/permission-gate.ts.
  • Replace all five inline deny/ask/allow branches with calls to it.
  • Add focused unit tests for the gate function.
  • Net-reduce src/index.ts by ~150 lines with no change to permission semantics.

Non-Goals

  • Splitting the tool_call / input handlers into separate files (tracked by #21 phase 2).
  • Changing any permission resolution logic, merge precedence, or default policy.
  • Refactoring promptPermission or writeReviewLog internals.

Background

The repeated pattern lives inside two event handlers registered by piPermissionSystemExtension() in src/index.ts:

Handler Surface Approx lines
input (skill) skill input gate 676717
tool_call skill-read path 762822
tool_call external-directory (file tools) 843894
tool_call external-directory (bash) 921978
tool_call normal tool permission 10001053

Key dependencies consumed inside the branches:

  • writeReviewLog — closure over the review-log writer.
  • promptPermission — closure that delegates to the permission dialog or auto-approves in yolo mode.
  • canRequestPermissionConfirmation(ctx) — pure check for interactive UI.
  • Various format* helpers from src/format-messages.ts.

The skill-input handler returns { action: "handled" } to block, while the tool_call handler returns { block: true, reason }. The gate function must be agnostic to this — it returns its own result type and each call site maps it to the handler's expected shape.

Design Overview

Types

/** Result of applying the permission gate. */
export type PermissionGateResult =
  | { action: "allow" }
  | { action: "block"; reason: string };

/** Everything the gate needs — no direct dependency on ExtensionContext. */
export interface PermissionGateParams {
  /** The resolved permission state from checkPermission(). */
  state: "allow" | "deny" | "ask";

  /** Whether the current context supports interactive prompts. */
  canConfirm: boolean;

  /** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
  promptForApproval: () => Promise<PermissionPromptDecision>;

  /** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
  writeLog: (event: string, extra: Record<string, unknown>) => void;

  /** Log context fields shared across all log calls for this gate. */
  logContext: Record<string, unknown>;

  /** Message strings/factories for each outcome. */
  messages: {
    denyReason: string;
    unavailableReason: string;
    userDeniedReason: (decision: PermissionPromptDecision) => string;
  };
}

Behaviour (pure decision logic)

  1. deny → call writeLog("permission_request.blocked", { ...logContext, resolution: "policy_denied" }), return { action: "block", reason: messages.denyReason }.
  2. ask + !canConfirm → call writeLog("permission_request.blocked", { ...logContext, resolution: "confirmation_unavailable" }), return { action: "block", reason: messages.unavailableReason }.
  3. ask + canConfirm → call promptForApproval(). If !decision.approved, return { action: "block", reason: messages.userDeniedReason(decision) }. Otherwise fall through.
  4. allow (or ask + approved) → return { action: "allow" }.

Call-site mapping

Each handler maps the gate result to its own return shape:

// tool_call handler
const result = await applyPermissionGate({ ... });
if (result.action === "block") return { block: true, reason: result.reason };

// input handler (skill)
const result = await applyPermissionGate({ ... });
if (result.action === "block") return { action: "handled" };

The skill-input handler currently shows a UI notification on deny before returning. That notification stays at the call site (before calling the gate or after inspecting its result); the gate itself is UI-agnostic.

Design decisions

  • promptForApproval is a pre-bound closure rather than passing ctx + details into the gate. This keeps the gate free of ExtensionContext and promptPermission signature coupling.
  • writeLog is a thin callback so the gate does not depend on the review-log writer's closure.
  • No writeLog call on the allow or user-approved path — those are logged by promptPermission internally (via reviewPermissionDecision), not by the gate.
  • The skill-input deny path's UI notification remains outside the gate at the call site, keeping the gate headless.

Module-Level Changes

src/permission-gate.ts (new)

  • Export PermissionGateResult, PermissionGateParams, applyPermissionGate.
  • Pure async function, no imports beyond the PermissionPromptDecision type from src/permission-dialog.ts.

src/index.ts (modified)

  • Import applyPermissionGate and its param/result types.
  • Replace the five inline deny/ask/allow blocks with calls to applyPermissionGate.
  • Each call site constructs PermissionGateParams from existing local variables and format helpers.
  • Net deletion: ~150 lines.

tests/permission-gate.test.ts (new)

  • Unit tests exercising each branch of applyPermissionGate in isolation with mock callbacks.

No changes to

  • schemas/permissions.schema.json, config/config.example.json, README.md — this is an internal refactor with no config or schema impact.
  • src/permission-manager.ts, src/permission-dialog.ts, src/format-messages.ts — consumed but not modified.

TDD Order

  1. Red: test applyPermissionGate returns { action: "block" } with deny reason when state === "deny", and calls writeLog with resolution: "policy_denied". Green: implement the deny branch in src/permission-gate.ts. test: permission-gate deny branch

  2. Red: test gate returns block with unavailable reason when state === "ask" and canConfirm === false, and calls writeLog with resolution: "confirmation_unavailable". Green: implement the ask-unavailable branch. test: permission-gate ask-unavailable branch

  3. Red: test gate calls promptForApproval and returns block with user-denied reason when the user rejects. Green: implement the ask-rejected branch. test: permission-gate ask-rejected branch

  4. Red: test gate calls promptForApproval and returns { action: "allow" } when user approves. Green: implement the ask-approved branch. test: permission-gate ask-approved path

  5. Red: test gate returns { action: "allow" } immediately when state === "allow" without calling writeLog or promptForApproval. Green: implement the allow fast-path. feat: add permission-gate module

  6. Refactor: replace all five inline branches in src/index.ts with applyPermissionGate calls. Run the full existing test suite to confirm no regressions. refactor: replace inline deny/ask/allow branches with applyPermissionGate

  7. Verify: run pnpm run build to confirm no type errors, then full npx vitest run. chore: verify clean build after permission-gate extraction

Risks and Mitigations

Risk Mitigation
Subtle semantic drift during extraction (e.g. missing a log field) Each call site is converted one at a time with the full test suite run after each batch. The gate's own unit tests cover every branch.
Could this silently weaken a permission? No — the gate is a strict refactor. The deny and ask branches produce identical block results. The allow path is unchanged. No new "allow" path is introduced.
Skill-input handler returns { action: "handled" } not { block, reason } The gate returns its own PermissionGateResult; each call site maps it. The skill-input site discards reason and returns { action: "handled" } as before.
promptForApproval closure captures stale variables Each closure is constructed fresh inside the event handler per invocation — same lifetime as the current inline code.

Open Questions

  • None — the issue is self-contained and the proposed API is straightforward. If #21 phase 2 (handler extraction) lands first, the call sites will move to different files, but the gate function itself is unaffected.