Files
my-pi/pi-permission-system/docs/plans/0478-narrow-resolver-resolve-intent.md
T

366 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 <gate> (#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 34 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