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

23 KiB
Raw Blame History

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 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.tsScopedPermissionManager 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":

// 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

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-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.tsToolAccessIntent, 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.tscheckPermissioncheck({ kind: "tool", … }).
  • src/skill-prompt-sanitizer.tscheckPermissioncheck({ kind: "tool", … }).
  • src/permission-event-rpc.tscheckPermissioncheck({ 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.tsresolveExternalDirectoryPolicy 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.tsmakeFakePermissionManager: replace checkPermission + checkPathPolicy stubs with a single check stub.
  • test/helpers/handler-fixtures.tsmakeHandler: 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.tsmakeResolver, makeGateRunner, makePathDispatchResolver: drop resolvePathPolicy; makePathDispatchResolver's single resolve dispatches on intent kind (toolinput.path; path-values → any matching value; access-pathpath.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-pathmatchValues() 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 resolveIntentresolve. 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.