23 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 478 | pi-permission-system: narrow ScopedPermissionResolver to a single resolve(intent) (Phase 6 Step 6) |
Narrow ScopedPermissionResolver to a single resolve(intent)
Release Recommendation
Release: ship independently
This is Phase 6 Step 6, tagged Release: independent in the architecture roadmap (it is not a member of either Phase 6 batch).
It is a self-contained, behavior-preserving structural refactor of the resolver/manager surface, so it ships on its own.
Problem Statement
The permission-resolution surface widens once per gate.
Gates today call either resolver.resolve(surface, input, agentName) (tool-shaped) or resolver.resolvePathPolicy(values, agentName, surface) (precomputed path values), and the underlying ScopedPermissionManager mirrors that split with checkPermission + checkPathPolicy.
Adding a gate can widen this surface again, and — because the surface is a method pair rather than a single entry — a test fixture can stub one method and forget the other.
That is the structural cause of the #393 false-green: a stubbed-but-unrouted checkPathPolicy silently returned its default (allow), a bug invisible in the edited test file and caught only by the full suite.
The fix is to make gates emit a minimal access intent that one resolve(intent) answers.
Adding a gate then cannot widen the resolver surface, and — by collapsing the manager to a single resolution method — the false-green class becomes structurally impossible (there is no second method to forget).
Goals
- Introduce a minimal
AccessIntentvalue that each gate emits (surface + value-or-AccessPath+agentName), carrying no principal identity. - Collapse
ScopedPermissionResolver.resolve+resolvePathPolicyinto oneresolve(intent). - Collapse
ScopedPermissionManager.checkPermission+checkPathPolicyinto onecheck(intent, sessionRules?), migrating every raw (non-gate) caller onto it. - Preserve all current behavior — this is a structural refactor, not a behavior change.
- Let
AccessPathflow into the resolver as a first-class intent variant (the resolver, not the gate, asks it formatchValues()), seeding theAccessPath-as-universal-representation direction (#487).
Non-Goals
- No change to what either path surface matches against.
The
pathsurface stays lexical-only;external_directorystays lexical ∪ canonical (the #418 set). Whetherpathshould also match the canonical form is tracked separately in #486. - No migration of bash-path's
path-valuesintent ontoAccessPath— that depends on #486 and is part of the broader #487 direction. - No principal identity on
AccessIntent; cross-session path portability stays deferred (#309 tracks the related advisory-path unification). - No change to the resolver's query methods (
getToolPermission,getConfigIssues) or to the resolver's rawcheckPermission(the no-session-rules path the skill-input gate depends on viaSkillInputGateInputs). - No change to
configuration.md— there is no user-facing behavior or config change.
Background
Relevant modules and how they relate:
src/permission-resolver.ts— theScopedPermissionResolverinterface (resolve+resolvePathPolicy) and thePermissionResolverclass. The class composes aScopedPermissionManagerwith aSessionRulesstore so gates never thread the session ruleset by hand (the #319 / #340 seam). The class also exposescheckPermission(raw, no session rules),getToolPermission, andgetConfigIssues— these are not on the narrow interface and are out of scope.src/permission-manager.ts—ScopedPermissionManagerinterface +PermissionManagerclass.checkPermission(toolName, input, agentName?, sessionRules?)runsnormalizeInput(needsgetConfiguredMcpServerNames()+currentCwd) thenbuildCheckResult.checkPathPolicy(values, agentName?, sessionRules?, surface="path")skips normalization, uses precomputed values, thenbuildCheckResult. Both already funnel through the shared privatebuildCheckResulthelper (#393).src/access-intent/access-path.ts— theAccessPathvalue object (#476);matchValues()returns the lexical alias union ∪ canonical for theexternal_directorysurface.- Gate descriptor factories that resolve:
src/handlers/gates/path.ts(resolve("path", {path})),bash-command.ts(threeresolve("bash", {command})calls),bash-path.ts(resolvePathPolicy(policyValues)),external-directory-policy.ts(resolvePathPolicy(path.matchValues(), …, "external_directory")), plus the tool-resolve site intool-call-gate-pipeline.tsand the descriptor-resolve site inrunner.ts. - Raw (non-gate) manager callers:
src/permissions-service.ts,src/skill-prompt-sanitizer.ts,src/permission-event-rpc.ts, and the resolver's own rawcheckPermission.
AGENTS.md / skill constraints that apply:
- The
architecture.md"ScopedPermissionResolver surface" health-metric row and the access-intent directory listing must be updated when this lands (the package skill's "module-move check misses narrative prose" rule). - The package skill's testing notes about wiring new manager/resolver methods through
makeHandler's surface dispatcher (#393 / #418) must be rewritten, because there is now a single method.
Design Overview
AccessIntent — the gate-emitted value
Three variants, modeling the three genuine ways a gate supplies "what is being accessed":
// src/access-intent/access-intent.ts
import type { AccessPath } from "#src/access-intent/access-path";
/** Raw tool input the manager must normalize (path/bash/MCP/extension tools). */
export interface ToolAccessIntent {
kind: "tool";
/** Tool name fed to input normalization (e.g. "read", "bash", "path", an MCP name). */
surface: string;
input: unknown;
agentName?: string;
}
/** Precomputed equivalent policy values for a path-shaped surface (bash-path). */
export interface PathValuesAccessIntent {
kind: "path-values";
/** "path" or "external_directory". */
surface: string;
values: readonly string[];
agentName?: string;
}
/** An AccessPath value object for a path-shaped surface (external-directory). */
export interface AccessPathAccessIntent {
kind: "access-path";
surface: string;
path: AccessPath;
agentName?: string;
}
/** What a gate emits. */
export type AccessIntent =
| ToolAccessIntent
| PathValuesAccessIntent
| AccessPathAccessIntent;
/** What the manager consumes — access-path already unwrapped to values. */
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
Why three variants and not two:
- The
toolvariant carries raw input only the manager can normalize (it needsgetConfiguredMcpServerNames()+currentCwd). - The
path-valuesvariant carries bash-path's cd-resolved lexicalstring[]for thepathsurface, which has no canonical-boundary notion. - The
access-pathvariant lets the external-directory gate hand itsAccessPathdirectly, soAccessPathflows into the resolver (a meaningful domain boundary) rather than being flattened at the gate. Forcing bash-path's plainstring[]into anAccessPathwould inject the canonical alias thepathsurface does not match today — a behavior change out of scope here.
Where the unwrap happens
The resolver unwraps the access-path variant via path.matchValues() (Tell-Don't-Ask: it asks the AccessPath for its match set) and hands a ResolvedAccessIntent (string-based) to the manager.
The low-level PermissionManager stays string-based — it never imports AccessPath.
Resolver
export interface ScopedPermissionResolver {
resolve(intent: AccessIntent): PermissionCheckResult;
}
// PermissionResolver class
resolve(intent: AccessIntent): PermissionCheckResult {
return this.permissionManager.check(
toResolvedIntent(intent),
this.sessionRules.getRuleset(),
);
}
toResolvedIntent is a private module helper:
function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
if (intent.kind === "access-path") {
return {
kind: "path-values",
surface: intent.surface,
values: intent.path.matchValues(),
agentName: intent.agentName,
};
}
return intent;
}
The class keeps checkPermission (raw, for skill-input), getToolPermission, and getConfigIssues unchanged — they remain off the narrow interface.
The raw checkPermission body now builds a tool intent and calls manager.check(intent, sessionRules).
Manager
export interface ScopedPermissionManager {
configureForCwd(cwd: string | undefined | null): void;
check(
intent: ResolvedAccessIntent,
sessionRules?: Ruleset,
): PermissionCheckResult;
getToolPermission(toolName: string, agentName?: string): PermissionState;
getConfigIssues(agentName?: string): string[];
}
// PermissionManager class
check(intent: ResolvedAccessIntent, sessionRules?: Ruleset): PermissionCheckResult {
const { composedRules } = this.resolvePermissions(intent.agentName);
const fullRules: Ruleset = sessionRules?.length
? [...composedRules, ...sessionRules]
: composedRules;
if (intent.kind === "path-values") {
const lookupValues = intent.values.length > 0 ? [...intent.values] : ["*"];
return buildCheckResult(
intent.surface, lookupValues, {}, intent.surface, intent.surface, fullRules,
);
}
const toolName = intent.surface.trim();
const { surface, values, resultExtras } = normalizeInput(
toolName, intent.input, this.loader.getConfiguredMcpServerNames(), this.currentCwd,
);
return buildCheckResult(
surface, values, resultExtras, toolName, intent.surface, fullRules,
);
}
The two branches are exactly the former checkPermission and checkPathPolicy bodies, preserving the trimmed-toolName-for-source / original-toolName-for-result distinction.
Consumer call-site sketches
External-directory policy helper (the AccessPath now flows into the resolver):
// external-directory-policy.ts
export function resolveExternalDirectoryPolicy(path, resolver, agentName) {
return resolver.resolve({
kind: "access-path",
surface: "external_directory",
path,
agentName,
});
}
bash-path gate (precomputed values):
// bash-path.ts
const check = resolver.resolve({
kind: "path-values",
surface: "path",
values: policyValues,
agentName: tcc.agentName ?? undefined,
});
Tool/path/bash-command/runner sites emit a tool intent:
const check = resolver.resolve({
kind: "tool",
surface: "path", // or "bash" / tcc.toolName / descriptor.surface
input: { path: filePath }, // or { command } / tcc.input / descriptor.input
agentName: tcc.agentName ?? undefined,
});
Edge cases preserved
- Empty
path-valuesfalls back to["*"](the formercheckPathPolicybehavior). - The
pathvsexternal_directorysurface tag still drivesevaluateAnyValue(last-match-wins across aliases) viaPATH_SURFACESinsidebuildCheckResult— unchanged. - The bash-command unparseable-command fail-closed sentinel (#452) is in
resolveBashCommandCheck, which now emitstoolintents; the sentinel path is untouched. - The
path-surface "only the universal default fired → skip gate" guard (#58) lives in the gate factories, not the resolver — untouched.
Module-Level Changes
Added
src/access-intent/access-intent.ts—ToolAccessIntent,PathValuesAccessIntent,AccessPathAccessIntent,AccessIntent,ResolvedAccessIntent.
Changed — production
src/permission-manager.ts— replacecheckPermission+checkPathPolicy(interface + class) withcheck(intent, sessionRules?); the two former bodies become the intent-kind branches;buildCheckResultunchanged.src/permission-resolver.ts— narrowScopedPermissionResolvertoresolve(intent: AccessIntent); removeresolvePathPolicy;resolvedelegates viatoResolvedIntent; rawcheckPermissionbody builds atoolintent; addtoResolvedIntentprivate helper.src/permissions-service.ts—checkPermission→check({ kind: "tool", … }).src/skill-prompt-sanitizer.ts—checkPermission→check({ kind: "tool", … }).src/permission-event-rpc.ts—checkPermission→check({ kind: "tool", … }).src/handlers/gates/path.ts— emit atoolintent.src/handlers/gates/bash-command.ts— three call sites emittoolintents.src/handlers/gates/bash-path.ts— emit apath-valuesintent.src/handlers/gates/external-directory-policy.ts—resolveExternalDirectoryPolicyemits anaccess-pathintent (drops the inlinepath.matchValues()call).src/handlers/gates/tool-call-gate-pipeline.ts— the tool-resolve site emits atoolintent.src/handlers/gates/runner.ts— the descriptor-resolve site emits atoolintent fromdescriptor.surface+descriptor.input.
Changed — tests and fixtures
test/helpers/session-fixtures.ts—makeFakePermissionManager: replacecheckPermission+checkPathPolicystubs with a singlecheckstub.test/helpers/handler-fixtures.ts—makeHandler: route thesurfaceCheckoverride onto the singlepermissionManager.checkvia an intent→(surface,input) adapter (replaces the dualcheckPermission/checkPathPolicyrouting); theMockGateHandlerSession.checkPermissionoverride surface is unchanged.test/helpers/gate-fixtures.ts—makeResolver,makeGateRunner,makePathDispatchResolver: dropresolvePathPolicy;makePathDispatchResolver's singleresolvedispatches on intent kind (tool→input.path;path-values→ any matching value;access-path→path.matchValues()).test/permission-manager-unified.test.ts— migratecheckPermission/checkPathPolicytests tocheck(intent).test/permission-resolver.test.ts— migrateresolve/resolvePathPolicytests toresolve(intent)(including anaccess-pathunwrap test).test/handlers/gates/path.test.ts,bash-path.test.ts,bash-external-directory.test.ts,external-directory-policy.test.ts,external-directory.test.ts,bash-command-metamorphic.test.ts— update resolver-mock assertions to the intent shape.test/handlers/external-directory-session-dedup.test.ts— update the inline manager mocks (checkPermission/checkPathPolicy) to the singlecheck.- Grep
test/for any inlineScopedPermissionResolver/ScopedPermissionManagermock not covered by the fixtures and migrate it in the same commit as the interface change.
Changed — docs
docs/architecture/architecture.md— mark Step 6 ✅ (heading + MermaidS6node); update the "ScopedPermissionResolver surface" health-metric row to met (resolve(intent)); rewrite thepermission-resolver.ts,permission-manager.ts,bash-path.ts, andexternal-directory-policy.tsdirectory-listing descriptions to the new surface; addaccess-intent.tsto the access-intent directory listing; refresh the line-622 resolver-surface-widening narrative..pi/skills/package-pi-permission-system/SKILL.md— rewrite themakeFakePermissionManager/makeResolver/makePathDispatchResolver/makeGateRunner/makeHandlerfixture notes for the singlecheck/resolve(intent); rewrite the #393 / #418 "wire the new method through the surface dispatcher" testing notes to state the false-green is now structurally impossible (one method).
Test Impact Analysis
- New unit tests enabled.
resolve(intent)can be tested per-variant in one place, including theaccess-path→matchValues()unwrap (previously only reachable indirectly through the external-directory gate).check(intent)can be tested per-kind directly on the manager. - Tests that become redundant.
The separate
checkPermissionvscheckPathPolicymanager test groups merge into intent-kind cases ofcheck. The separateresolvevsresolvePathPolicyresolver test groups merge into intent-variant cases ofresolve. Consolidate, do not duplicate. - Tests that must stay.
The gate behavior tests (path, bash-path, external-directory single/bash, bash-command chain) still exercise gate → resolver → manager end-to-end; they only change the asserted mock shape.
The #393 / #418 integration tests (
external-directory-session-dedup.test.ts, thetool-call.test.tsbash-path/external-directory blocks) stay — they pin that the unification did not reintroduce a silentallow.
Invariants at risk
This step touches surfaces earlier Phase 6 / earlier-phase steps refactored. List and pin:
- #393 false-green class — pinned by the
external-directory-session-dedupandtool-callintegration tests routing through real instances. After unification the class is structurally impossible (singlecheck); the tests must still pass green. - #418 external_directory alias matching (lexical ∪ canonical) — pinned by
bash-external-directory.test.ts/external-directory.test.tsasserting both typed and symlink-resolved patterns match; theaccess-pathvariant must resolve the samematchValues()set. - #452 bash fail-closed sentinel — pinned by the bash-command unparseable-command tests;
resolveBashCommandCheckkeeps the sentinel. - #58 universal-default skip on
path— pinned by the path-gate tests asserting no prompt when only the universal default fired. - #306 / #301 bash chain most-restrictive — pinned by
bash-command-metamorphic.test.ts.
All invariants live in existing tests; none rely on prose only, so no new pinning test is required beyond the migrated assertions.
TDD Order
Lift-and-shift where an existing name's signature changes, to avoid a single giant test rewrite (per the testing skill).
- Add
AccessIntenttypes + managercheck(intent)alongside the old pair. Newsrc/access-intent/access-intent.ts; addcheckto theScopedPermissionManagerinterface +PermissionManagerclass (delegating through the existingbuildCheckResult), leavingcheckPermission/checkPathPolicyin place; add acheckstub tomakeFakePermissionManagerand route it inmakeHandleralongside the existing dispatch. Red:permission-manager-unified.test.tscases forcheckcoveringtoolandpath-valuesintents. Commit:feat(pi-permission-system): add ScopedPermissionManager.check(intent) (#478). - Migrate manager callers to
check; removecheckPermission/checkPathPolicy. Switch resolver internals (resolve/resolvePathPolicy/rawcheckPermissionbodies),permissions-service.ts,skill-prompt-sanitizer.ts,permission-event-rpc.tstocheck; remove the old pair from the interface + class; drop the old stubs frommakeFakePermissionManager,makeHandler, and the inline dedup-test mocks; migratepermission-manager-unified.test.ts. One commit (interface removal breaks all manager mocks at the type level). Runpnpm run checkimmediately after. The resolver's public surface is unchanged here, so gates and resolver fixtures are untouched. Commit:refactor(pi-permission-system): route all callers through manager.check (#478). - Add resolver
resolveIntent(intent)alongside the old pair; migrate gates incrementally. AddresolveIntentto the interface + class (withtoResolvedIntent); add aresolveIntentstub tomakeResolver/makeGateRunner/makePathDispatchResolveralongside the existingresolve/resolvePathPolicy; add resolver-level tests for all three intent variants. Then migrate each gate + its tests toresolveIntent, one commit per gate:path.ts,bash-command.ts(+ thetool-call-gate-pipelinetool-resolve site +runner.ts),bash-path.ts,external-directory-policy.ts(+ external-directory test files). Commits:feat(pi-permission-system): add resolver resolveIntent seam (#478)thenrefactor(pi-permission-system): emit AccessIntent from <gate> (#478)per gate. - Remove
resolve(surface,input)+resolvePathPolicy; renameresolveIntent→resolve. Drop the old pair from the interface + class + fixtures; renameresolveIntenttoresolveacross the migrated call sites and tests; migratepermission-resolver.test.tsto the finalresolve(intent). One commit (mechanical rename + final interface narrowing). Runpnpm run checkimmediately after. Commit:refactor(pi-permission-system): narrow ScopedPermissionResolver to resolve(intent) (#478). - Docs.
Update
architecture.md(Step 6 ✅, surface metric, directory descriptions, access-intent listing) and the packageSKILL.mdfixture/testing notes. Commit:docs(pi-permission-system): record resolve(intent) narrowing (#478).
If /tdd-plan judges the per-gate lift-and-shift heavier than an atomic resolver narrowing (only six production call sites), it may collapse steps 3–4 into a single atomic interface-change commit — the interface removal forces all consumers into one commit either way.
Risks and Mitigations
- Large interface-removal commits.
Both the manager (step 2) and resolver (step 4) removals break every typed mock at once.
Mitigation: lift-and-shift the new method in first (steps 1, 3), grep
test/for inline mocks before the removal commit, and runpnpm run checkimmediately after each removal. - Silent behavior change in the
tool-intent branch. The trimmed-vs-original tool-name distinction (deriveSourceuses trimmed; the result reports original) must be preserved. Mitigation: keepbuildCheckResult(surface, values, extras, trimmedToolName, originalSurface, fullRules)argument order; the manager-unified tests assertsourceandtoolName. AccessPathcoupling creep. Mitigation: the manager consumesResolvedAccessIntent(noAccessPath); only the resolver importsAccessPath, viatoResolvedIntent.- Reintroducing the #393 false-green during migration. Mitigation: the integration tests route through real instances; keep them green at every step.
Open Questions
- Should the
pathsurface match the canonical form likeexternal_directory? Filed as #486; resolving it gates the bash-path →AccessPathmigration. - Adopt
AccessPathas the universal internal path representation? Filed as #487; this step'spath-valuesvariant is the transitional accommodation that shrinks under that direction.