12 KiB
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
PermissionResolverinterface exposing a singleresolve(surface, input, agentName?)operation. - Have
PermissionSessionimplement it by composingcheckPermissionwithgetSessionRulesetinternally. - Migrate all four gate descriptor producers and
resolveBashCommandCheckto depend onPermissionResolverinstead of thecheckPermission+getSessionRulesetpair. - Replace the
checkPermission+getSessionRulesetmembers of theGateRunnerDepsbag withresolve. - Keep the change behavior-preserving.
Non-Goals
- Extracting the
DecisionReporter(writeReviewLog+emitDecision) collaborator — that is #322. - Replacing
GateRunnerDepswith aGateRunnerclass injected with role collaborators, and adding theGatePrompterrole — that is #323. - Changing any permission decision, log entry, or decision-event payload.
- Touching
handleInput(it callssession.checkPermissiondirectly with no session-rule relay and is out of scope).
Background
src/handlers/gates/runner.ts(runGateCheck) resolves the check viadeps.checkPermission(surface, input, agent, deps.getSessionRuleset())unlesspreCheck/preResolvedshort-circuits it.src/handlers/gates/{path,bash-path,bash-external-directory}.tseach declare a localCheckPermissionFntype and take(checkPermission, getSessionRuleset); each callsgetSessionRuleset()once, thencheckPermission(..., sessionRules)one or more times.src/handlers/gates/bash-command.ts(resolveBashCommandCheck) takes(command, commands, agentName, sessionRules, checkPermission)and callscheckPermission(..., sessionRules)per command unit.src/handlers/permission-gate-handler.tsbuildscheckPermissionandgetSessionRulesetclosures overthis.session, threads them into every gate producer and the inline tool-gate resolution, and packs them into theGateRunnerDepsbag.PermissionSessionalready exposes bothcheckPermission(surface, input, agentName?, sessionRules?)andgetSessionRuleset().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 migrationresolvere-snapshots per call. Because norecordSessionApprovalhappens during descriptor construction, every snapshot within a gate is equal — behavior-preserving (see Risks). resolveBashCommandCheck's empty-commandsfallback still callsresolve("bash", { command }, agentName), matching the prior whole-commandcheckPermissionfallback.GateRunnerDepskeepsresolvealigned with the interface by extending it (interface GateRunnerDeps extends PermissionResolver { … }).
Module-Level Changes
src/permission-resolver.ts— new: thePermissionResolverinterface.src/permission-session.ts— addimplements PermissionResolverand theresolvemethod; import the interface type.src/handlers/gates/path.ts— replace thecheckPermission+getSessionRulesetparams with a singleresolver: PermissionResolver; drop the localCheckPermissionFntype; callresolver.resolve(...).src/handlers/gates/bash-path.ts— same migration; the per-token loop callsresolver.resolve(...).src/handlers/gates/bash-external-directory.ts— same migration.src/handlers/gates/bash-command.ts—resolveBashCommandCheckdrops thesessionRulesandcheckPermissionparams for a singleresolver: PermissionResolver; drop the localCheckPermissionFntype.src/handlers/gates/descriptor.ts—GateRunnerDeps: removecheckPermissionandgetSessionRuleset;extends PermissionResolverto gainresolve.src/handlers/gates/runner.ts— resolve the check viadeps.resolve(descriptor.surface, descriptor.input, agentName ?? undefined).src/handlers/permission-gate-handler.ts— exposethis.sessionasPermissionResolverto every gate producer and the inline tool-gate resolution; set the bag'sresolve; remove the now-unusedcheckPermissionandgetSessionRulesetclosures.test/helpers/gate-fixtures.ts— addmakeResolver(overrides)returning{ resolve: vi.fn() }; updatemakeRunnerDepsto exposeresolveinstead ofcheckPermission+getSessionRuleset.test/handlers/gates/{path,bash-path,bash-external-directory,bash-command,runner}.test.ts— inject a resolver mock; assert onresolver.resolve(surface, input, agentName)(three args, no ruleset) instead ofcheckPermission(..., sessionRules).docs/architecture/architecture.md— addpermission-resolver.tsto thesrc/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
- New unit tests enabled:
PermissionSession.resolvecan 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. - Tests simplified: the five gate/runner test files drop the separate
getSessionRulesetmock and the four-argumentcheckPermissionassertion, asserting instead on a single three-argumentresolver.resolvecall — fewer moving parts per test. - 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 === undefinedhandling); only the injected collaborator and its assertion shape change.
TDD Order
- Add
PermissionResolver+PermissionSession.resolve. Surface:test/permission-session.test.ts. Covers:resolveforwardssurface/input/agentNameand applies the session ruleset; reflects a recorded approval on the nextresolve. Commit:feat: add PermissionResolver.resolve to PermissionSession. - Migrate
describePathGatetoPermissionResolver; addmakeResolvertogate-fixtures.ts; updatepath.test.tsand 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. - Migrate
describeBashExternalDirectoryGate; update its test and the handler call site. Commit:refactor: migrate describeBashExternalDirectoryGate to PermissionResolver. - Migrate
describeBashPathGate; update its test and the handler call site. Commit:refactor: migrate describeBashPathGate to PermissionResolver. - Migrate
resolveBashCommandCheckand the inline tool-gate resolution inhandleToolCall; updatebash-command.test.ts. Commit:refactor: migrate resolveBashCommandCheck to PermissionResolver. - Replace the bag's
checkPermission+getSessionRulesetwithresolve: updateGateRunnerDeps(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. - 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 2–5, 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 perresolveinstead of once per gate. Mitigation: norecordSessionApprovalruns 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
makeResolverfixture and one-gate-per-commit sequencing keep each diff small and reviewable. - Inline tool-gate coupling:
handleToolCallresolves the tool check via bothresolveBashCommandCheckand a directcheckPermissioncall. Mitigation: migrate both in step 5 so the inline path flips toresolveatomically.
Open Questions
- The home and grouping of the remaining roles (
GatePrompter,SessionApprovalRecorder,DecisionReporter) are deferred to #322 and #323; this plan introduces onlyPermissionResolver.