20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 340 | Extract a PermissionResolver collaborator out of PermissionSession |
Extract a PermissionResolver collaborator out of PermissionSession
Problem Statement
PermissionSession is a god object that implements six role interfaces.
One of those roles — permission resolution — is a cohesive cluster of methods (resolve / checkPermission / getToolPermission / getConfigIssues / getPolicyCacheStamp) that is currently fused into the session.
Because the resolution logic lives on the session, every test that wants to exercise resolution must build a full session fixture (the 17-field makeSession intersection mock).
This is Phase 4, Step 7 (Track C: split the session) of the pi-permission-system improvement roadmap.
It promotes PermissionResolver from a one-method interface (resolve) into a concrete collaborator that holds the PermissionManager + SessionRules and owns the whole resolution surface, so the resolve role becomes a distinct, directly unit-testable object.
Goals
- Promote
permission-resolver.tsto a concretePermissionResolverclass holdingScopedPermissionManager+SessionRules, owningresolve/checkPermission/getToolPermission/getConfigIssues/getPolicyCacheStamp. - Rename the narrow
{ resolve }role interface (currentlyPermissionResolver) toScopedPermissionResolverso the concrete class can take the canonical name. - Route
GateRunner,ToolCallGatePipeline, andSkillInputGatePipelinethrough the new resolver for the resolve / check role. - Remove the resolve role from
PermissionSession(drop theresolvemethod and theScopedPermissionResolverimplements clause). - Keep the change behavior-preserving — the full suite stays green at every step.
Non-Goals
- Removing
checkPermission/getToolPermission/getConfigIssues/getPolicyCacheStampfromPermissionSession. These remain (delegating to the session's ownPermissionManager) because theAgentPrepSession,SessionLifecycleSession, andSkillPermissionCheckerinterfaces still depend on them. Removing them and unwinding those fig-leaf interfaces is Step 8 (#341). - Rewiring
AgentPrepHandler/SessionLifecycleHandlerto depend on the resolver — deferred to Step 8. - Touching
LocalPermissionsService(permissions-service.ts), which keeps its own directPermissionManagerdelegation. - Any change to permission decision semantics, config format, schema, or docs beyond the architecture/skill descriptions.
Background
Relevant modules (see docs/architecture/architecture.md):
src/permission-resolver.ts— currently just thePermissionResolverinterface (resolve(surface, input, agentName)); the relay-collapsing abstraction introduced in #319. Implemented byPermissionSession.src/permission-session.ts— the god object. ImplementsPermissionResolver,SessionApprovalRecorder,GateHandlerSession,AgentPrepSession,SessionLifecycleSession. Holds the injectedScopedPermissionManager+SessionRules. ItsresolvecomposescheckPermissionwithgetSessionRuleset().src/handlers/gates/runner.ts—GateRunneris constructed with aPermissionResolverand callsresolver.resolve(...).src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGateInputs extends PermissionResolver; the pipeline is constructed withsessionand usesthis.inputs.resolve(...)plus three query methods (getActiveSkillEntries,getInfrastructureReadDirs,getToolPreviewLimits).src/handlers/gates/skill-input-gate-pipeline.ts—SkillInputGateInputsis a narrow{ checkPermission }; constructed withsession.- Gate descriptor factories
path.ts,bash-command.ts,bash-external-directory.ts,bash-path.ts— each takes aresolver: PermissionResolverand callsresolver.resolve(...). src/index.ts— composition root. ConstructspermissionManager,sessionRules,session, and wiresnew GateRunner(session, session, gateway, reporter),new ToolCallGatePipeline(session, formatterRegistry),new SkillInputGatePipeline(session).
Precedent from Step 6 (#339, PromptingGateway): the prompting role was fully removed from PermissionSession and GateRunner was rewired to a distinct collaborator.
This step mirrors that for resolution.
Naming follows the established role-interface + concrete-class convention (ScopedPermissionManager + PermissionManager, GatePrompter + PromptingGateway, DecisionReporter + GateDecisionReporter).
Per the user decision on this issue, the concrete class takes the canonical name PermissionResolver and the narrow role interface is renamed ScopedPermissionResolver — symmetric with ScopedPermissionManager (the narrow session-scoped contract the concrete class implements).
Constraint from AGENTS.md / code-design: a shared interface referencing a collaborator must use a narrow interface type, not the concrete class — the gate factories' test mocks are plain objects ({ resolve }), so the { resolve } interface must survive as a distinct type from the class.
Design Overview
The narrow role interface
// permission-resolver.ts — the resolve role the gate factories / runner / pipeline need.
export interface ScopedPermissionResolver {
resolve(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult;
}
The concrete class
// permission-resolver.ts — the concrete collaborator holding the manager + rules.
export class PermissionResolver implements ScopedPermissionResolver {
constructor(
private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: Pick<SessionRules, "getRuleset">,
) {}
resolve(surface: string, input: unknown, agentName?: string): PermissionCheckResult {
return this.checkPermission(surface, input, agentName, this.sessionRules.getRuleset());
}
checkPermission(surface: string, input: unknown, agentName?: string, sessionRules?: Rule[]): PermissionCheckResult {
return this.permissionManager.checkPermission(surface, input, agentName, sessionRules);
}
getToolPermission(toolName: string, agentName?: string): PermissionState {
return this.permissionManager.getToolPermission(toolName, agentName);
}
getConfigIssues(agentName?: string): string[] {
return this.permissionManager.getConfigIssues(agentName);
}
getPolicyCacheStamp(agentName?: string): string {
return this.permissionManager.getPolicyCacheStamp(agentName);
}
}
Notes on the dependency contract:
- The constructor accepts
ScopedPermissionManager(the narrow interface), not the concretePermissionManager, so unit tests pass a fake manager without anas unknown ascast. - The session-rules dependency is narrowed to
Pick<SessionRules, "getRuleset">(ISP — the resolver only reads the ruleset; it never records approvals). Unit tests can pass a realnew SessionRules()or a{ getRuleset: () => rules }stub. getToolPermission/getConfigIssues/getPolicyCacheStampare included per the issue's resolution surface even though no current resolver consumer calls them; Step 8 rewiresAgentPrepHandler/SessionLifecycleHandlerto use them.
Consumer call sites
GateRunner (unchanged body; constructor param type only):
// runner.ts
constructor(
private readonly resolver: ScopedPermissionResolver, // was PermissionResolver
private readonly recorder: SessionApprovalRecorder,
private readonly prompter: GatePrompter,
private readonly reporter: DecisionReporter,
) {}
// ... this.resolver.resolve(descriptor.surface, descriptor.input, agentName ?? undefined)
ToolCallGatePipeline (resolver split out of the query inputs):
// tool-call-gate-pipeline.ts
export interface ToolCallGateInputs { // no longer extends ScopedPermissionResolver
getActiveSkillEntries(): SkillPromptEntry[];
getInfrastructureReadDirs(): string[];
getToolPreviewLimits(): ToolPreviewFormatterOptions;
}
constructor(
private readonly resolver: ScopedPermissionResolver,
private readonly inputs: ToolCallGateInputs,
private readonly customFormatters?: ToolInputFormatterLookup,
) {}
// gate factories now receive this.resolver; query methods stay on this.inputs:
// describePathGate(tcc, this.resolver)
// describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver)
// describeBashPathGate(tcc, bashProgram, this.resolver)
// resolveBashCommandCheck(command ?? "", bashProgram.commands(), agentName, this.resolver)
// this.resolver.resolve(tcc.toolName, tcc.input, ...)
// this.inputs.getActiveSkillEntries() / getInfrastructureReadDirs() / getToolPreviewLimits()
index.ts (construct the resolver once; share the same permissionManager + sessionRules instances the session holds):
const resolver = new PermissionResolver(permissionManager, sessionRules);
// ...
const gateRunner = new GateRunner(resolver, session, gateway, reporter);
const toolCallGatePipeline = new ToolCallGatePipeline(resolver, session, formatterRegistry);
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
SkillInputGatePipeline needs no interface change — the PermissionResolver class satisfies SkillInputGateInputs ({ checkPermission }) structurally; only the construction site moves from session to resolver.
Separation of concerns / shared-instance contract
After this step, both PermissionSession and PermissionResolver hold references to the same permissionManager and sessionRules instances (injected from the composition root — never reconstructed).
PermissionSession keeps the manager for lifecycle (configureForCwd in resetForNewSession / reload) and the transitional query methods; the rules for getSessionRuleset / recordSessionApproval / clear.
PermissionResolver reads them for resolution.
There is no split-brain because the instances are identical — this mirrors the shared-instance contract established when ExtensionRuntime was dissolved in #337.
Edge cases
- Raw vs. session-scoped check:
resolveappliessessionRules.getRuleset();checkPermission(called bySkillInputGatePipelinewith three args) intentionally passes no session rules — the raw skill-input semantics from #326 are preserved because the 4th argument stays optional. - Empty session ruleset:
resolveforwards[]when no approvals are recorded (identical to the current session behavior).
Module-Level Changes
Source:
src/permission-resolver.ts— rename interfacePermissionResolver→ScopedPermissionResolver; add concreteclass PermissionResolver implements ScopedPermissionResolver(constructorScopedPermissionManager+Pick<SessionRules, "getRuleset">; methodsresolve,checkPermission,getToolPermission,getConfigIssues,getPolicyCacheStamp). Add imports forScopedPermissionManager,SessionRules,Rule,PermissionState(types).src/permission-session.ts— dropresolvemethod; removeScopedPermissionResolver(formerlyPermissionResolver) from theimplementslist and its import. KeepcheckPermission/getToolPermission/getConfigIssues/getPolicyCacheStamp(transitional; removed in Step 8).src/handlers/gates/runner.ts— import + constructor paramPermissionResolver→ScopedPermissionResolver.src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGateInputsno longer extends the resolve interface (becomes the three query methods); addresolver: ScopedPermissionResolveras the first constructor param; route gate factories + tool resolve throughthis.resolver. Update the doc comment.src/handlers/gates/path.ts,bash-command.ts,bash-external-directory.ts,bash-path.ts— import +resolverparam typePermissionResolver→ScopedPermissionResolver.src/index.ts— constructnew PermissionResolver(permissionManager, sessionRules); rewireGateRunner(first arg →resolver),ToolCallGatePipeline(prependresolver),SkillInputGatePipeline(session→resolver).
src/handlers/gates/skill-input-gate-pipeline.ts is unchanged (only its construction site in index.ts moves).
Tests:
test/permission-resolver.test.ts— new: unit tests for the concrete class (no session fixture).test/permission-session.test.ts— remove thedescribe("resolve")block (moves to the resolver test);makePermissionManagerand the surviving delegation tests stay.test/helpers/gate-fixtures.ts—makeResolver/makeGateRunner/makeGateInputstype referencesPermissionResolver["resolve"]→ScopedPermissionResolver["resolve"];makeGateInputsdrops theresolvefield (now produced bymakeResolver). Imports updated.test/handlers/gates/tool-call-gate-pipeline.test.ts— constructnew ToolCallGatePipeline(resolver, inputs, ...); theresolve-override test (makeGateInputs({ resolve })) switches tomakeResolver({ ... }).test/handlers/gates/skill-input-gate-pipeline.test.ts— no construction change (stillnew SkillInputGatePipeline(inputs)viamakeSkillInputInputs, which is structurally a resolver subset); verify it still type-checks.test/handlers/gates/bash-external-directory.test.ts,bash-path.test.ts— importPermissionResolvertype →ScopedPermissionResolver.
Docs:
docs/architecture/architecture.md— update the module-structure entries forpermission-resolver.ts(now interface + concrete class),permission-session.ts(implements four interfaces, resolve role removed), andrunner.ts(constructed withScopedPermissionResolver); decrement the "role interfaces implemented by one class" metric (5 → 4) in the constructibility table. (The Step 7✓ completemarker on the roadmap step line is appended during/ship-issue, per the package skill.).pi/skills/package-pi-permission-system/SKILL.md— update themakeResolverdescription (PermissionResolvermock →ScopedPermissionResolvermock) andmakeGateInputs(no longer stubsresolve).
Test Impact Analysis
- New unit tests enabled by the extraction:
test/permission-resolver.test.tsexercisesresolve(applies the session ruleset; defaultsagentNametoundefined; returns the manager's result; applies a recorded approval), andcheckPermission/getToolPermission/getConfigIssues/getPolicyCacheStampdelegation — all by constructingnew PermissionResolver(fakeManager, new SessionRules())with no session fixture. This is the headline win: the resolve role is now testable withoutmakeSession. - Redundant tests: the
describe("resolve")block intest/permission-session.test.ts(four cases) duplicates the new resolver tests onceresolvemoves off the session — removed in the same step that removessession.resolve. - Tests that must stay as-is: the session's
checkPermission/getToolPermission/getConfigIssues/getPolicyCacheStampdelegation tests (the session keeps those methods until Step 8); theAgentPrepHandler/SessionLifecycleHandlertests (still depend on the session interfaces); the gate-factory and runner tests (still driveresolvethrough the narrow interface, nowScopedPermissionResolver).
TDD Order
-
Rename the narrow interface
PermissionResolver→ScopedPermissionResolver(refactor). Mechanical rename acrosspermission-resolver.tsand every type-importer (runner.ts,tool-call-gate-pipeline.ts,path.ts,bash-command.ts,bash-external-directory.ts,bash-path.ts,permission-session.tsimplements clause,test/helpers/gate-fixtures.ts,test/handlers/gates/bash-external-directory.test.ts,test/handlers/gates/bash-path.test.ts). No behavior change; the existing suite is the regression guard. Runpnpm run checkafter committing (a renamed export breaks all importers in one commit — this is the atomic rename step). Commit:refactor: rename PermissionResolver interface to ScopedPermissionResolver (#340). -
Add the concrete
PermissionResolverclass; routeGateRunner+SkillInputGatePipelinethrough it (test + feat). Red→green: writetest/permission-resolver.test.tsagainst the new class (resolve + four delegations), then implement the class inpermission-resolver.ts. Constructnew PermissionResolver(permissionManager, sessionRules)inindex.ts; pass it asGateRunner's first arg and asSkillInputGatePipeline's constructor arg.session.resolvestill exists and is still used byToolCallGatePipeline, so the suite stays green. Commit:feat: add PermissionResolver class and route gate runner through it (#340). -
Inject the resolver into
ToolCallGatePipeline(refactor + test). NarrowToolCallGateInputsto the three query methods; addresolver: ScopedPermissionResolveras the first constructor param; route gate factories + tool resolve throughthis.resolver. Updateindex.ts(new ToolCallGatePipeline(resolver, session, formatterRegistry)),makeGateInputs(dropresolve), andtool-call-gate-pipeline.test.ts(pass amakeResolver(...)resolver; move theresolve-override case onto it). These land together because narrowing the interface and constructing the pipeline are type-coupled (single call site inindex.ts). Commit:refactor: inject resolver into ToolCallGatePipeline (#340). -
Remove the resolve role from
PermissionSession(refactor). With no remaining consumer ofsession.resolve, delete the method and theScopedPermissionResolverimplements clause (and its import); remove the now-redundantdescribe("resolve")block fromtest/permission-session.test.ts. Commit:refactor: remove resolve role from PermissionSession (#340). -
Update architecture and skill docs (docs). Update the
docs/architecture/architecture.mdmodule-structure entries (permission-resolver.ts,permission-session.ts,runner.ts) and decrement the role-interfaces metric (5 → 4); update themakeResolver/makeGateInputsdescriptions in.pi/skills/package-pi-permission-system/SKILL.md. Commit:docs: update architecture and skill for PermissionResolver extraction (#340).
Risks and Mitigations
- Risk: the session and resolver hold different
PermissionManager/SessionRulesinstances (split-brain). Mitigation:index.tsinjects the same instances into both; neither reconstructs them. Verified bytest/composition-root.test.ts(shared-instance contract). - Risk: a missed
session.resolveconsumer breaks at runtime, not at type-check. Mitigation: grep confirms the onlyresolvecallers are the gate factories,GateRunner, andToolCallGatePipeline, all rewired before Step 4 removes the method; run the full suite (not just changed files) before each commit. - Risk: narrowing
ToolCallGateInputs(droppingresolve) silently leaves a staleresolvefield in a fixture. Mitigation: updatemakeGateInputsand the pipeline test in the same step (Step 3);pnpm run checkflags excess/missing properties. - Risk: the interface rename misses an importer.
Mitigation: dedicated rename step (Step 1) followed immediately by
pnpm run check.
Open Questions
- Step 8 (#341) removes the transitional
checkPermission/getToolPermission/getConfigIssues/getPolicyCacheStampfromPermissionSession, rewiresAgentPrepHandler/SessionLifecycleHandlerto the resolver, and unwinds the fig-leaf interfaces. The exact disposition ofSkillPermissionChecker(whetherAgentPrepHandlerpasses the resolver toresolveSkillPromptEntries) is decided there, not here.