--- issue: 478 issue_title: "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 `AccessIntent` value that each gate emits (surface + value-or-`AccessPath` + `agentName`), carrying no principal identity. - Collapse `ScopedPermissionResolver.resolve` + `resolvePathPolicy` into one `resolve(intent)`. - Collapse `ScopedPermissionManager.checkPermission` + `checkPathPolicy` into one `check(intent, sessionRules?)`, migrating every raw (non-gate) caller onto it. - Preserve all current behavior — this is a structural refactor, not a behavior change. - Let `AccessPath` flow into the resolver as a first-class intent variant (the resolver, not the gate, asks it for `matchValues()`), seeding the `AccessPath`-as-universal-representation direction ([#487]). ## Non-Goals - No change to what either path surface matches against. The `path` surface stays lexical-only; `external_directory` stays lexical ∪ canonical (the [#418] set). Whether `path` should also match the canonical form is tracked separately in [#486]. - No migration of bash-path's `path-values` intent onto `AccessPath` — 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 raw `checkPermission` (the no-session-rules path the skill-input gate depends on via `SkillInputGateInputs`). - 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` — the `ScopedPermissionResolver` interface (`resolve` + `resolvePathPolicy`) and the `PermissionResolver` class. The class composes a `ScopedPermissionManager` with a `SessionRules` store so gates never thread the session ruleset by hand (the [#319] / [#340] seam). The class also exposes `checkPermission` (raw, no session rules), `getToolPermission`, and `getConfigIssues` — these are **not** on the narrow interface and are out of scope. - `src/permission-manager.ts` — `ScopedPermissionManager` interface + `PermissionManager` class. `checkPermission(toolName, input, agentName?, sessionRules?)` runs `normalizeInput` (needs `getConfiguredMcpServerNames()` + `currentCwd`) then `buildCheckResult`. `checkPathPolicy(values, agentName?, sessionRules?, surface="path")` skips normalization, uses precomputed values, then `buildCheckResult`. Both already funnel through the shared private `buildCheckResult` helper ([#393]). - `src/access-intent/access-path.ts` — the `AccessPath` value object ([#476]); `matchValues()` returns the lexical alias union ∪ canonical for the `external_directory` surface. - Gate descriptor factories that resolve: `src/handlers/gates/path.ts` (`resolve("path", {path})`), `bash-command.ts` (three `resolve("bash", {command})` calls), `bash-path.ts` (`resolvePathPolicy(policyValues)`), `external-directory-policy.ts` (`resolvePathPolicy(path.matchValues(), …, "external_directory")`), plus the tool-resolve site in `tool-call-gate-pipeline.ts` and the descriptor-resolve site in `runner.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 raw `checkPermission`. 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": ```typescript // 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 `tool` variant carries raw input only the manager can normalize (it needs `getConfiguredMcpServerNames()` + `currentCwd`). - The `path-values` variant carries bash-path's cd-resolved lexical `string[]` for the `path` surface, which has no canonical-boundary notion. - The `access-path` variant lets the external-directory gate hand its `AccessPath` directly, so `AccessPath` flows into the resolver (a meaningful domain boundary) rather than being flattened at the gate. Forcing bash-path's plain `string[]` into an `AccessPath` would inject the canonical alias the `path` surface 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 ```typescript 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: ```typescript 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 ```typescript 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): ```typescript // 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): ```typescript // 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: ```typescript 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-values` falls back to `["*"]` (the former `checkPathPolicy` behavior). - The `path` vs `external_directory` surface tag still drives `evaluateAnyValue` (last-match-wins across aliases) via `PATH_SURFACES` inside `buildCheckResult` — unchanged. - The bash-command unparseable-command fail-closed sentinel ([#452]) is in `resolveBashCommandCheck`, which now emits `tool` intents; 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` — replace `checkPermission` + `checkPathPolicy` (interface + class) with `check(intent, sessionRules?)`; the two former bodies become the intent-kind branches; `buildCheckResult` unchanged. - `src/permission-resolver.ts` — narrow `ScopedPermissionResolver` to `resolve(intent: AccessIntent)`; remove `resolvePathPolicy`; `resolve` delegates via `toResolvedIntent`; raw `checkPermission` body builds a `tool` intent; add `toResolvedIntent` private 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 a `tool` intent. - `src/handlers/gates/bash-command.ts` — three call sites emit `tool` intents. - `src/handlers/gates/bash-path.ts` — emit a `path-values` intent. - `src/handlers/gates/external-directory-policy.ts` — `resolveExternalDirectoryPolicy` emits an `access-path` intent (drops the inline `path.matchValues()` call). - `src/handlers/gates/tool-call-gate-pipeline.ts` — the tool-resolve site emits a `tool` intent. - `src/handlers/gates/runner.ts` — the descriptor-resolve site emits a `tool` intent from `descriptor.surface` + `descriptor.input`. ### Changed — tests and fixtures - `test/helpers/session-fixtures.ts` — `makeFakePermissionManager`: replace `checkPermission` + `checkPathPolicy` stubs with a single `check` stub. - `test/helpers/handler-fixtures.ts` — `makeHandler`: route the `surfaceCheck` override onto the single `permissionManager.check` via an intent→(surface,input) adapter (replaces the dual `checkPermission`/`checkPathPolicy` routing); the `MockGateHandlerSession.checkPermission` override surface is unchanged. - `test/helpers/gate-fixtures.ts` — `makeResolver`, `makeGateRunner`, `makePathDispatchResolver`: drop `resolvePathPolicy`; `makePathDispatchResolver`'s single `resolve` dispatches on intent kind (`tool` → `input.path`; `path-values` → any matching value; `access-path` → `path.matchValues()`). - `test/permission-manager-unified.test.ts` — migrate `checkPermission`/`checkPathPolicy` tests to `check(intent)`. - `test/permission-resolver.test.ts` — migrate `resolve`/`resolvePathPolicy` tests to `resolve(intent)` (including an `access-path` unwrap 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 single `check`. - Grep `test/` for any inline `ScopedPermissionResolver` / `ScopedPermissionManager` mock 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 + Mermaid `S6` node); update the "ScopedPermissionResolver surface" health-metric row to met (`resolve(intent)`); rewrite the `permission-resolver.ts`, `permission-manager.ts`, `bash-path.ts`, and `external-directory-policy.ts` directory-listing descriptions to the new surface; add `access-intent.ts` to the access-intent directory listing; refresh the line-622 resolver-surface-widening narrative. - `.pi/skills/package-pi-permission-system/SKILL.md` — rewrite the `makeFakePermissionManager` / `makeResolver` / `makePathDispatchResolver` / `makeGateRunner` / `makeHandler` fixture notes for the single `check` / `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 1. **New unit tests enabled.** `resolve(intent)` can be tested per-variant in one place, including the `access-path` → `matchValues()` unwrap (previously only reachable indirectly through the external-directory gate). `check(intent)` can be tested per-kind directly on the manager. 2. **Tests that become redundant.** The separate `checkPermission` vs `checkPathPolicy` manager test groups merge into intent-kind cases of `check`. The separate `resolve` vs `resolvePathPolicy` resolver test groups merge into intent-variant cases of `resolve`. Consolidate, do not duplicate. 3. **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`, the `tool-call.test.ts` bash-path/external-directory blocks) stay — they pin that the unification did not reintroduce a silent `allow`. ## 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-dedup` and `tool-call` integration tests routing through real instances. After unification the class is structurally impossible (single `check`); the tests must still pass green. - **[#418] external_directory alias matching (lexical ∪ canonical)** — pinned by `bash-external-directory.test.ts` / `external-directory.test.ts` asserting both typed and symlink-resolved patterns match; the `access-path` variant must resolve the same `matchValues()` set. - **[#452] bash fail-closed sentinel** — pinned by the bash-command unparseable-command tests; `resolveBashCommandCheck` keeps 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). 1. **Add `AccessIntent` types + manager `check(intent)` alongside the old pair.** New `src/access-intent/access-intent.ts`; add `check` to the `ScopedPermissionManager` interface + `PermissionManager` class (delegating through the existing `buildCheckResult`), leaving `checkPermission`/`checkPathPolicy` in place; add a `check` stub to `makeFakePermissionManager` and route it in `makeHandler` alongside the existing dispatch. Red: `permission-manager-unified.test.ts` cases for `check` covering `tool` and `path-values` intents. Commit: `feat(pi-permission-system): add ScopedPermissionManager.check(intent) (#478)`. 2. **Migrate manager callers to `check`; remove `checkPermission`/`checkPathPolicy`.** Switch resolver internals (`resolve`/`resolvePathPolicy`/raw `checkPermission` bodies), `permissions-service.ts`, `skill-prompt-sanitizer.ts`, `permission-event-rpc.ts` to `check`; remove the old pair from the interface + class; drop the old stubs from `makeFakePermissionManager`, `makeHandler`, and the inline dedup-test mocks; migrate `permission-manager-unified.test.ts`. One commit (interface removal breaks all manager mocks at the type level). Run `pnpm run check` immediately 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)`. 3. **Add resolver `resolveIntent(intent)` alongside the old pair; migrate gates incrementally.** Add `resolveIntent` to the interface + class (with `toResolvedIntent`); add a `resolveIntent` stub to `makeResolver`/`makeGateRunner`/`makePathDispatchResolver` alongside the existing `resolve`/`resolvePathPolicy`; add resolver-level tests for all three intent variants. Then migrate each gate + its tests to `resolveIntent`, one commit per gate: `path.ts`, `bash-command.ts` (+ the `tool-call-gate-pipeline` tool-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)` then `refactor(pi-permission-system): emit AccessIntent from (#478)` per gate. 4. **Remove `resolve(surface,input)` + `resolvePathPolicy`; rename `resolveIntent` → `resolve`.** Drop the old pair from the interface + class + fixtures; rename `resolveIntent` to `resolve` across the migrated call sites and tests; migrate `permission-resolver.test.ts` to the final `resolve(intent)`. One commit (mechanical rename + final interface narrowing). Run `pnpm run check` immediately after. Commit: `refactor(pi-permission-system): narrow ScopedPermissionResolver to resolve(intent) (#478)`. 5. **Docs.** Update `architecture.md` (Step 6 ✅, surface metric, directory descriptions, access-intent listing) and the package `SKILL.md` fixture/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 run `pnpm run check` immediately after each removal. - **Silent behavior change in the `tool`-intent branch.** The trimmed-vs-original tool-name distinction (`deriveSource` uses trimmed; the result reports original) must be preserved. Mitigation: keep `buildCheckResult(surface, values, extras, trimmedToolName, originalSurface, fullRules)` argument order; the manager-unified tests assert `source` and `toolName`. - **`AccessPath` coupling creep.** Mitigation: the manager consumes `ResolvedAccessIntent` (no `AccessPath`); only the resolver imports `AccessPath`, via `toResolvedIntent`. - **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 `path` surface match the canonical form like `external_directory`? Filed as [#486]; resolving it gates the bash-path → `AccessPath` migration. - Adopt `AccessPath` as the universal internal path representation? Filed as [#487]; this step's `path-values` variant is the transitional accommodation that shrinks under that direction. [#58]: https://github.com/gotgenes/pi-packages/issues/58 [#301]: https://github.com/gotgenes/pi-packages/issues/301 [#306]: https://github.com/gotgenes/pi-packages/issues/306 [#309]: https://github.com/gotgenes/pi-packages/issues/309 [#319]: https://github.com/gotgenes/pi-packages/issues/319 [#340]: https://github.com/gotgenes/pi-packages/issues/340 [#393]: https://github.com/gotgenes/pi-packages/issues/393 [#418]: https://github.com/gotgenes/pi-packages/issues/418 [#452]: https://github.com/gotgenes/pi-packages/issues/452 [#476]: https://github.com/gotgenes/pi-packages/issues/476 [#486]: https://github.com/gotgenes/pi-packages/issues/486 [#487]: https://github.com/gotgenes/pi-packages/issues/487