Files
my-pi/pi-permission-system/docs/plans/0319-introduce-permission-resolver.md
T

12 KiB
Raw Blame History

issue, issue_title
issue issue_title
319 Introduce PermissionResolver and remove the session-rule relay from the permission gates

Introduce PermissionResolver and remove the session-rule relay

Problem Statement

Every permission gate is handed two functions, checkPermission and getSessionRuleset, but getSessionRuleset is never used on its own. At all five call sites — runGateCheck plus describePathGate, describeBashPathGate, describeBashExternalDirectoryGate, and resolveBashCommandCheck — the ruleset is fetched only to be handed straight back into the next checkPermission call:

const sessionRules = getSessionRuleset();
const check = checkPermission(surface, input, agent, sessionRules);

So the pair is not two collaborators; it is one operation — "resolve the effective permission, applying the current session rules" — split into a primitive plus a relay. This is the first step of a larger rework: the GateRunnerDeps closure bag in handleToolCall conflates this relay with four genuine roles, and the relay must go before the roles become visible.

Goals

  • Define a narrow PermissionResolver interface exposing a single resolve(surface, input, agentName?) operation.
  • Have PermissionSession implement it by composing checkPermission with getSessionRuleset internally.
  • Migrate all four gate descriptor producers and resolveBashCommandCheck to depend on PermissionResolver instead of the checkPermission + getSessionRuleset pair.
  • Replace the checkPermission + getSessionRuleset members of the GateRunnerDeps bag with resolve.
  • Keep the change behavior-preserving.

Non-Goals

  • Extracting the DecisionReporter (writeReviewLog + emitDecision) collaborator — that is #322.
  • Replacing GateRunnerDeps with a GateRunner class injected with role collaborators, and adding the GatePrompter role — that is #323.
  • Changing any permission decision, log entry, or decision-event payload.
  • Touching handleInput (it calls session.checkPermission directly with no session-rule relay and is out of scope).

Background

  • src/handlers/gates/runner.ts (runGateCheck) resolves the check via deps.checkPermission(surface, input, agent, deps.getSessionRuleset()) unless preCheck/preResolved short-circuits it.
  • src/handlers/gates/{path,bash-path,bash-external-directory}.ts each declare a local CheckPermissionFn type and take (checkPermission, getSessionRuleset); each calls getSessionRuleset() once, then checkPermission(..., sessionRules) one or more times.
  • src/handlers/gates/bash-command.ts (resolveBashCommandCheck) takes (command, commands, agentName, sessionRules, checkPermission) and calls checkPermission(..., sessionRules) per command unit.
  • src/handlers/permission-gate-handler.ts builds checkPermission and getSessionRuleset closures over this.session, threads them into every gate producer and the inline tool-gate resolution, and packs them into the GateRunnerDeps bag.
  • PermissionSession already exposes both checkPermission(surface, input, agentName?, sessionRules?) and getSessionRuleset().
  • SessionRules.getRuleset() returns a fresh array copy ([...this.rules]) on each call.

Constraint from AGENTS.md / code-design: when a shared interface references a collaborator, use a narrow interface type, not the concrete class; keep Pi SDK imports out of the new pure module.

Design Overview

A single new role interface:

// src/permission-resolver.ts
import type { PermissionCheckResult } from "./types";

/**
 * Resolves the effective permission for a surface/input, applying the
 * current session rules internally. Collapses the checkPermission +
 * getSessionRuleset relay that every gate previously threaded by hand.
 */
export interface PermissionResolver {
  resolve(
    surface: string,
    input: unknown,
    agentName?: string,
  ): PermissionCheckResult;
}

PermissionSession implements it:

class PermissionSession implements PermissionResolver {
  resolve(
    surface: string,
    input: unknown,
    agentName?: string,
  ): PermissionCheckResult {
    return this.checkPermission(
      surface,
      input,
      agentName,
      this.getSessionRuleset(),
    );
  }
}

Gate consumer call site (replaces the (checkPermission, getSessionRuleset) pair):

// describePathGate, after migration
const check = resolver.resolve("path", { path: filePath }, tcc.agentName ?? undefined);

The module is a pure type — no SDK imports, no behavior — so the session imports it downward (./permission-resolver) and the gates/runner import it via #src/permission-resolver. No import cycle: the resolver references only PermissionCheckResult from types.ts.

Edge cases:

  • Multi-check gates (describeBashPathGate, describeBashExternalDirectoryGate) previously snapshotted the ruleset once and reused it across token checks; after migration resolve re-snapshots per call. Because no recordSessionApproval happens during descriptor construction, every snapshot within a gate is equal — behavior-preserving (see Risks).
  • resolveBashCommandCheck's empty-commands fallback still calls resolve("bash", { command }, agentName), matching the prior whole-command checkPermission fallback.
  • GateRunnerDeps keeps resolve aligned with the interface by extending it (interface GateRunnerDeps extends PermissionResolver { … }).

Module-Level Changes

  • src/permission-resolver.tsnew: the PermissionResolver interface.
  • src/permission-session.ts — add implements PermissionResolver and the resolve method; import the interface type.
  • src/handlers/gates/path.ts — replace the checkPermission + getSessionRuleset params with a single resolver: PermissionResolver; drop the local CheckPermissionFn type; call resolver.resolve(...).
  • src/handlers/gates/bash-path.ts — same migration; the per-token loop calls resolver.resolve(...).
  • src/handlers/gates/bash-external-directory.ts — same migration.
  • src/handlers/gates/bash-command.tsresolveBashCommandCheck drops the sessionRules and checkPermission params for a single resolver: PermissionResolver; drop the local CheckPermissionFn type.
  • src/handlers/gates/descriptor.tsGateRunnerDeps: remove checkPermission and getSessionRuleset; extends PermissionResolver to gain resolve.
  • src/handlers/gates/runner.ts — resolve the check via deps.resolve(descriptor.surface, descriptor.input, agentName ?? undefined).
  • src/handlers/permission-gate-handler.ts — expose this.session as PermissionResolver to every gate producer and the inline tool-gate resolution; set the bag's resolve; remove the now-unused checkPermission and getSessionRuleset closures.
  • test/helpers/gate-fixtures.ts — add makeResolver(overrides) returning { resolve: vi.fn() }; update makeRunnerDeps to expose resolve instead of checkPermission + getSessionRuleset.
  • test/handlers/gates/{path,bash-path,bash-external-directory,bash-command,runner}.test.ts — inject a resolver mock; assert on resolver.resolve(surface, input, agentName) (three args, no ruleset) instead of checkPermission(..., sessionRules).
  • docs/architecture/architecture.md — add permission-resolver.ts to the src/ file tree; reframe the Phase 3 Track C roadmap entry (old Step 6 "GateRunnerContext narrow interface") into the three-step decomposition (#319 resolver, #322 reporter, #323 GateRunner), and update the matching Mermaid node and Track C summary row.

No removed or renamed public exports; getSessionRuleset and checkPermission remain on PermissionSession (still used by resolve, handleInput, and other callers). A repo-wide grep confirms no other consumer imports the gates' local CheckPermissionFn types (they are file-private).

Test Impact Analysis

  1. New unit tests enabled: PermissionSession.resolve can now be tested in isolation — that it forwards the surface/input/agent and applies the current session ruleset. This composition was previously implicit in every gate's wiring and never unit-tested on its own.
  2. Tests simplified: the five gate/runner test files drop the separate getSessionRuleset mock and the four-argument checkPermission assertion, asserting instead on a single three-argument resolver.resolve call — fewer moving parts per test.
  3. Tests that stay as-is: every gate descriptor test keeps exercising its gate's branching logic (null/bypass/descriptor, most-restrictive selection, backward-compat matchedPattern === undefined handling); only the injected collaborator and its assertion shape change.

TDD Order

  1. Add PermissionResolver + PermissionSession.resolve. Surface: test/permission-session.test.ts. Covers: resolve forwards surface/input/agentName and applies the session ruleset; reflects a recorded approval on the next resolve. Commit: feat: add PermissionResolver.resolve to PermissionSession.
  2. Migrate describePathGate to PermissionResolver; add makeResolver to gate-fixtures.ts; update path.test.ts and the handler call site (handler keeps the old closures for the not-yet-migrated gates and the runner bag). Commit: refactor: migrate describePathGate to PermissionResolver.
  3. Migrate describeBashExternalDirectoryGate; update its test and the handler call site. Commit: refactor: migrate describeBashExternalDirectoryGate to PermissionResolver.
  4. Migrate describeBashPathGate; update its test and the handler call site. Commit: refactor: migrate describeBashPathGate to PermissionResolver.
  5. Migrate resolveBashCommandCheck and the inline tool-gate resolution in handleToolCall; update bash-command.test.ts. Commit: refactor: migrate resolveBashCommandCheck to PermissionResolver.
  6. Replace the bag's checkPermission + getSessionRuleset with resolve: update GateRunnerDeps (extends PermissionResolver), runner.ts, makeRunnerDeps, runner.test.ts, and remove the handler's now-unused closures. Commit: refactor: resolve via PermissionResolver in the gate runner.
  7. Update docs/architecture/architecture.md (file tree + Phase 3 Track C roadmap reframing). Commit: docs: reframe Phase 3 Track C into the gate-runner collaborator decomposition.

Each step changes one gate's signature plus its single handler call site and its test in the same commit — the type checker would reject splitting them. The handler carries both the resolver and the legacy closures through steps 25, so the repo stays green between commits; step 6 deletes the last closures once no consumer remains.

Risks and Mitigations

  • Per-call ruleset snapshot: getRuleset() copies the array each call, so multi-token gates now snapshot per resolve instead of once per gate. Mitigation: no recordSessionApproval runs during descriptor construction, so all snapshots within a gate are identical; the result is unchanged and the extra allocations are negligible for realistic ruleset/token sizes.
  • Mechanical breadth: five gate/runner test files change their injected collaborator. Mitigation: a shared makeResolver fixture and one-gate-per-commit sequencing keep each diff small and reviewable.
  • Inline tool-gate coupling: handleToolCall resolves the tool check via both resolveBashCommandCheck and a direct checkPermission call. Mitigation: migrate both in step 5 so the inline path flips to resolve atomically.

Open Questions

  • The home and grouping of the remaining roles (GatePrompter, SessionApprovalRecorder, DecisionReporter) are deferred to #322 and #323; this plan introduces only PermissionResolver.