Files
my-pi/pi-permission-system/docs/plans/0503-service-rpc-access-path.md
T

28 KiB
Raw Blame History

issue, issue_title
issue issue_title
503 pi-permission-system: migrate the service/RPC path queries onto AccessPath (Phase 7 Step 2)

Migrate the service/RPC path queries onto AccessPath

Release Recommendation

Release: mid-batch — defer (batch "symlink-resistant-path-matching"); confirm at ship time

This is Phase 7 Step 2 of the #487 roadmap. The architecture's Release batches subsection puts Steps 1, 2, 3 in the breaking batch "symlink-resistant-path-matching", with the tail at Step 3 (#504). This issue is Step 2, not the tail, so its breaking feat!: commits land on main and auto-batch; the major-bump release cuts when Step 3 lands. Confirm the deferral at ship time.

Problem Statement

External callers query our policy two ways: the Symbol.for() service (LocalPermissionsService.checkPermission) and the deprecated event-bus RPC (permissions:rpc:check). Both build a query input with buildInputForSurface and resolve a kind: "tool" intent, which the manager normalizes lexically only via normalizeInputnormalizePathSurfaceValuesgetPathPolicyValues. So an external caller asking "would this path be allowed?" for path / external_directory / a path-bearing surface gets lexical-only matching — inconsistent with the gates, which match the lexical aliases canonical (symlink-resolved) set after #486 and Phase 7 Step 1 (#502). A query for a symlinked path therefore misses a rule that fires on the canonical alias at the gate.

This is the second of the two residual lexical-only path-derivation paths Phase 7 closes (Step 1 closed the per-tool gate). Route the service/RPC path queries through AccessPath and the resolver so external policy queries match the same set the gates do.

Goals

  • For path / external_directory / path-bearing surface queries (the PATH_SURFACES set) with a non-empty value, build an AccessPath from the value and emit a kind: "access-path" intent, so external policy queries match the lexical aliases canonical form — at parity with the gates.
  • Route both the service and RPC check paths through the resolver (resolve(intent)), which already unwraps access-pathpath-values via matchValues(), keeping the manager string-based. This makes the resolver the sole path-values producer, the premise Phase 7 Step 5 (#506) decides the boundary against.
  • Keep non-path surfaces (bash, skill, mcp, extension tools) and value-less surface-level queries on the existing kind: "tool" intent — ["*"] fallback preserved.
  • Narrow the service and RPC collaborators: the resolver subsumes the manager + SessionRules pair (it composes the session ruleset internally), so each consumer holds one resolution collaborator plus the session's PathNormalizer.

This is a breaking change for external consumers: a service/RPC path query now resolves against the canonical alias too. Two observable shifts on upgrade with no caller edit:

  1. An external_directory query for a symlinked path now matches a rule on its canonical target where it previously matched lexically only.
  2. A path or path-bearing-tool query (read / write / edit / grep / find / ls) now evaluates the supplied path instead of collapsing to ["*"] — see Background (latent gap).

The behavior commits are feat(pi-permission-system)!: with a BREAKING CHANGE: footer.

Non-Goals

  • No removal of input-normalizer's normalizePathSurfaceValues / special-surface / PATH_BEARING_TOOLS branches — that is Phase 7 Step 3 (#504), after Steps 1 and 2 strip their last gate/service/RPC callers. This plan leaves normalizeInput intact; the per-tool gate's missing-path case still routes through it for the ["*"] fallback.
  • No decision on the path-values boundary (formalize the string seam vs. move the unwrap into the manager) — that is Phase 7 Step 5 (#506). This plan only advances its premise (resolver = sole producer).
  • No change to the RPC prompt handler (permissions:rpc:prompt) — it shows a dialog and never resolves policy.
  • No change to path-utils.ts derivation consolidation — that is Phase 7 Step 4 (#505).
  • No change to dedup/approval-key identity or principal identity on AccessIntent.

Background

Relevant modules (all in packages/pi-permission-system/):

  • src/permissions-service.tsLocalPermissionsService.checkPermission(surface, value, agentName) builds buildInputForSurface(surface, value) and calls permissionManager.check({ kind: "tool", surface, input, agentName }, sessionRules.getRuleset()). getToolPermission delegates to permissionManager.getToolPermission. Constructed in index.ts with (permissionManager, sessionRules, formatterRegistry, accessExtractorRegistry) (narrowed in #366).
  • src/permission-event-rpc.tshandleCheckRpc builds buildInputForSurface(surface, value) and calls deps.permissionManager.check({ kind: "tool", ... }, deps.sessionRules.getRuleset()). The deprecated channel (/* eslint-disable @typescript-eslint/no-deprecated */ at the top). handlePromptRpc uses value only for the UI prompt display; it never resolves policy.
  • src/input-normalizer.tsbuildInputForSurface(surface, value) is the inverse of normalizeInput: it builds the minimal tool-intent input from a (surface, value) pair. It maps bash{ command }, skill{ name }, external_directory{ path }, and everything else (including path and the path-bearing tools) → {} — so the value is dropped for those surfaces.
  • src/permission-resolver.tsPermissionResolver.resolve(intent: AccessIntent) reduces a gate-emitted intent to the manager's ResolvedAccessIntent, unwrapping access-pathpath-values via path.matchValues(), and composes sessionRules.getRuleset() internally so callers never thread it. Also exposes getToolPermission, getConfigIssues, checkPermission (the no-session-rules skill path). Constructed in index.ts as new PermissionResolver(permissionManager, sessionRules)after the service and RPC today (line ~177 vs. ~145 / ~137); the move-up is mechanical (it depends only on permissionManager + sessionRules).
  • src/permission-session.tsgetPathNormalizer(): PathNormalizer returns the session's normalizer, rebuilt on each activate(ctx) / resetForNewSession so it tracks the active cwd. A placeholder (new PathNormalizer(platform, "")) until the first activate binds the real cwd.
  • src/path-normalizer.tsPathNormalizer.forPath(value) builds an AccessPath resolved against the baked session cwd + platform.
  • src/access-intent/access-intent.tsAccessPathAccessIntent (kind: "access-path"); its doc comment names the gate emitters (the path/external_directory/per-tool surfaces).
  • src/path-utils.tsPATH_SURFACES = PATH_BEARING_TOOLS { "external_directory", "path" }.
  • src/value-guards.tsgetNonEmptyString(value): string | null (trims; null for empty/whitespace).

Latent gap this fixes

Because buildInputForSurface only wires the value into external_directory (returns { path }) and the catch-all {} for path and the path-bearing tools, a service/RPC query like checkPermission("read", "/etc/passwd") today normalizes to ["*"] — the supplied path is silently dropped (asserted today by test/service.test.ts: checkPermission("read", "/tmp/file") → input {}). So in practice the only meaningful path query was external_directory. Building an AccessPath for the whole PATH_SURFACES set fixes this drop as a natural consequence — path and path-bearing queries now evaluate the supplied path. This is part of the breaking surface and is documented as such.

Constraints (AGENTS.md / SKILL)

  • The manager stays string-based and never imports AccessPath; the resolver does the matchValues() unwrap. This plan preserves that — the service/RPC emit access-path to the resolver, the resolver unwraps, the manager is untouched.
  • Default to least privilege: the change only widens the match set (more rules can fire), never loosens — no ask/deny becomes allow.

Design Overview

Routing decision: through the resolver, not a second path-values producer

The manager's check accepts only ResolvedAccessIntent (tool | path-values); it cannot consume an access-path intent. Two ways to give the service/RPC canonical parity:

  1. Have the service/RPC build the AccessPath, call matchValues() themselves, and pass a path-values intent to manager.check.
  2. Have the service/RPC emit an access-path intent to resolver.resolve, which unwraps it.

Option 1 makes the service/RPC a second path-values producer, contradicting the premise Phase 7 Step 5 (#506) decides against ("with the resolver the sole path-values producer after Steps 1 and 2"). Option 2 is chosen: it routes both consumers through the resolver — the single unwrap site — and is a clean 1:1 substitution for today's manager.check(..., sessionRules.getRuleset()), since resolver.resolve does exactly that plus the unwrap.

Shared intent builder (input-normalizer.ts)

Add buildAccessIntentForSurface, the surface→intent mapping shared by the service and RPC. It builds an access-path intent for a PATH_SURFACES surface carrying a non-empty value, and a tool intent (via the existing buildInputForSurface) otherwise:

import type { AccessIntent } from "./access-intent/access-intent";
import type { PathNormalizer } from "./path-normalizer";
import { PATH_SURFACES } from "./path-utils";
import { getNonEmptyString } from "./value-guards";

export function buildAccessIntentForSurface(
  surface: string,
  value: string | undefined,
  normalizer: PathNormalizer,
  agentName: string | undefined,
): AccessIntent {
  const pathValue = getNonEmptyString(value);
  if (pathValue !== null && PATH_SURFACES.has(surface)) {
    return { kind: "access-path", surface, path: normalizer.forPath(pathValue), agentName };
  }
  return { kind: "tool", surface, input: buildInputForSurface(surface, value), agentName };
}

buildInputForSurface stays exported (still used here for the tool branch, and imported by test/service.test.ts). No import cycle: path-normalizer.ts and access-intent/access-intent.ts do not import input-normalizer.ts; PathNormalizer / AccessIntent are import type.

The getNonEmptyString guard preserves the value-less surface-level query (checkPermission("path")tool["*"]) and the whitespace-only case, matching today's normalizePathSurfaceValues ["*"] fallback.

Service (permissions-service.ts)

Swap the (permissionManager, sessionRules) pair for a single resolver plus the session's PathNormalizer provider:

interface ResolverForService {
  resolve(intent: AccessIntent): PermissionCheckResult;
  getToolPermission(toolName: string, agentName?: string): PermissionState;
}
interface PathNormalizerProvider {
  getPathNormalizer(): PathNormalizer;
}

export class LocalPermissionsService implements PermissionsService {
  constructor(
    private readonly resolver: ResolverForService,
    private readonly session: PathNormalizerProvider,
    private readonly formatterRegistry: ToolInputFormatterRegistrar,
    private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
  ) {}

  checkPermission(surface, value, agentName) {
    const intent = buildAccessIntentForSurface(
      surface, value, this.session.getPathNormalizer(), agentName,
    );
    return this.resolver.resolve(intent);
  }

  getToolPermission(toolName, agentName) {
    return this.resolver.getToolPermission(toolName, agentName);
  }
  // registerToolInputFormatter / registerToolAccessExtractor unchanged
}

PermissionResolver satisfies ResolverForService; PermissionSession satisfies PathNormalizerProvider. getPathNormalizer() is fetched per call (the normalizer rebinds on cwd change), and the published service always answers against the parent session's cwd (a child never publishes, #302). The service holds one resolution collaborator — narrower than today's manager + SessionRules, keeping the #366 narrowing intent (4 fields → 4 fields, but the resolution surface collapses to one).

RPC (permission-event-rpc.ts)

PermissionRpcDeps drops permissionManager and sessionRules, gains resolver, and extends the narrow session view with getPathNormalizer:

export interface PermissionRpcDeps {
  resolver: Pick<ScopedPermissionResolver, "resolve">;
  session: {
    getRuntimeContext(): ExtensionContext | null;
    getPathNormalizer(): PathNormalizer;
  };
  requestPermissionDecisionFromUi(/* … */): Promise<PermissionPromptDecision>;
  logger: ReviewLogger;
}

handleCheckRpc builds the intent and resolves:

const intent = buildAccessIntentForSurface(
  surface, value, deps.session.getPathNormalizer(), agentName ?? undefined,
);
const result = deps.resolver.resolve(intent);

The reply shape (result.state / matchedPattern / origin) is unchanged. handlePromptRpc is untouched (it uses neither collaborator).

Composition root (index.ts)

  • Move const resolver = new PermissionResolver(permissionManager, sessionRules); up to before registerPermissionRpcHandlers (the only ordering change; downstream consumers reference the same const).
  • RPC deps: { resolver, session, requestPermissionDecisionFromUi, logger } (the full PermissionSession satisfies both narrow session needs).
  • new LocalPermissionsService(resolver, session, formatterRegistry, accessExtractorRegistry).

Call-site interaction sketch (Law of Demeter / Tell-Don't-Ask)

The service hands the normalizer to the builder rather than reaching through it:

const normalizer = this.session.getPathNormalizer(); // a.b() — the documented session accessor
const intent = buildAccessIntentForSurface(surface, value, normalizer, agentName);
return this.resolver.resolve(intent); // resolver owns the unwrap + session-rule composition

forPath is invoked inside the builder, not by the service — no session.getPathNormalizer().forPath(...) chain at the consumer. This mirrors the gate pipeline's established inputs.getPathNormalizer() → builder convention.

Edge cases

  • Value-less / whitespace-only path query: getNonEmptyStringnulltool intent → ["*"] (preserved).
  • Non-path surface (bash/skill/mcp/extension): tool intent via buildInputForSurface (unchanged).
  • Not a symlink: matchValues() collapses to the lexical aliases — no spurious extra value.
  • Unresolvable path (ELOOP / EACCES): AccessPath's canonical step falls back to the lexical form — no new match beyond today's lexical behavior.
  • Child session: never publishes the service; the parent's normalizer answers external queries.

Module-Level Changes

Source:

  • src/input-normalizer.ts — add buildAccessIntentForSurface (path-surface → access-path, else tool); keep buildInputForSurface exported as the tool-branch input builder. Add imports: PATH_SURFACES (#src/path-utils), getNonEmptyString is already imported, type imports AccessIntent (#src/access-intent/access-intent) and PathNormalizer (#src/path-normalizer).
  • src/permissions-service.ts — constructor takes (resolver, session, formatterRegistry, accessExtractorRegistry); checkPermission builds the intent via buildAccessIntentForSurface and calls resolver.resolve; getToolPermission delegates to resolver.getToolPermission. Define local ResolverForService + PathNormalizerProvider interfaces; drop the ScopedPermissionManager / SessionRules / buildInputForSurface imports, add buildAccessIntentForSurface, AccessIntent, PathNormalizer, PermissionState type imports.
  • src/permission-event-rpc.tsPermissionRpcDeps drops permissionManager + sessionRules, adds resolver: Pick<ScopedPermissionResolver, "resolve">, extends session with getPathNormalizer; handleCheckRpc builds the intent and calls deps.resolver.resolve. Swap the ScopedPermissionManager import for ScopedPermissionResolver, drop buildInputForSurface, add buildAccessIntentForSurface and a PathNormalizer type import.
  • src/index.ts — move the resolver construction above the RPC registration; pass resolver + session into the RPC deps and LocalPermissionsService.
  • src/access-intent/access-intent.ts — update the AccessPathAccessIntent doc comment: emitters now also include the service/RPC path queries (not only the gates).

Tests:

  • test/input-normalizer.test.ts — add a buildAccessIntentForSurface describe: a PATH_SURFACES surface (path, external_directory, read) with a value emits access-path whose path.matchValues() carries the canonical alias (use the node:fs realpathSync mock convention from path.test.ts for the symlink case); a non-path surface (bash) emits tool with buildInputForSurface input; an empty/whitespace value on a path surface emits tool (["*"] path via the manager).
  • test/permissions-service.test.ts — rewrite to inject a fake resolver (resolve + getToolPermission stubs) and a real PathNormalizer provider; assert checkPermission("bash", "echo hi") calls resolver.resolve with a tool intent; checkPermission("external_directory", "/sym/link") and checkPermission("read", "/p") call it with an access-path intent whose path matches the expected lexical canonical set; a value-less path query falls to tool; getToolPermission delegates to resolver.getToolPermission. Drop the vi.mock("#src/input-normalizer") buildInputForSurface stub.
  • test/permission-event-rpc.test.tsmakeDeps swaps permissionManager + sessionRules for resolver: { resolve: vi.fn() } and adds session.getPathNormalizer; the existing allow/deny check-RPC tests assert on resolver.resolve instead of permissionManager.check; add a path-surface RPC test asserting an access-path intent reaches the resolver (canonical alias in the match set).
  • test/service.test.ts — update the "service adapter delegation" describe: replace the hand-rolled buildInputForSurface adapter (which simulated the old index.ts wiring) with the current buildAccessIntentForSurface + resolver pattern, so the round-trip tests document the new wiring; the stale checkPermission("read", "/tmp/file") → {} assertion is replaced by an access-path-intent assertion.
  • test/composition-root.test.ts — the existing checkPermission("demo") queries (non-path) stay green through the new wiring; add a path-surface service query (checkPermission("path", <symlink>)) asserting canonical matching end-to-end via the real factory (the harness fires session_start, so getPathNormalizer() is cwd-bound).

Documentation (grep-verified — symbol/behavior named in prose):

  • docs/architecture/architecture.md — mark Phase 7 Step 2 ([#503]) complete ( on the step heading ~line 801 and the S2 Mermaid node ~line 835); update the permissions-service.ts entry (~line 716) and permission-event-rpc.ts entry (~line 720) to note they route path-surface queries through the resolver as access-path; rewrite the Phase-7 intro framing (~line 768, "Two ad-hoc path-derivation paths remain") and the residual "Service/RPC queries" bullet (~line 788) to past tense now both access-side parity migrations (Steps 1 and 2) have landed. Leave the health-metric/target table (~line 778) unchanged (it describes the phase endpoint, not a per-step state — per the #502 precedent).
  • docs/cross-extension-api.md — the checkPermission section (~lines 8194) and the RPC permissions:rpc:check section (~line 444): add that path / external_directory / path-bearing path values now match the canonical (symlink-resolved) form, at parity with the gates, and that a path-bearing-surface query now evaluates the supplied path (previously collapsed to *).
  • .pi/skills/package-pi-permission-system/SKILL.md — update the LocalPermissionsService note (~line 118) to record that path-surface service/RPC queries route through the resolver as access-path (canonical parity), fetching the session PathNormalizer per call.

README is not updated: it documents config surfaces, not the getPermissionsService query API, and already describes symlink-resistant path/per-tool matching.

Test Impact Analysis

  1. New tests the change enables:
    • buildAccessIntentForSurface as a directly unit-testable surface→intent mapping (no manager round-trip needed) — the symlink-canonical match set is asserted on the built AccessPath.
    • A service/RPC path query whose canonical alias matches a deny (e.g. external_directory reached via a symlink) — assertable with a fake resolver dispatching on intent.kind / intent.surface.
    • A read/path query now evaluates the supplied path (the latent-gap fix), replacing the old → {} drop assertion.
  2. Tests that become redundant: the test/service.test.ts hand-rolled-adapter tests that simulated the old buildInputForSurface index.ts wiring lose their reason to exist as wiring docs — folded into the rewritten "service adapter delegation" block (current wiring) and permissions-service.test.ts (real class). No test is deleted outright.
  3. Tests that must stay as-is: the existing permission-event-rpc.test.ts reply-shape / error-path / prompt-RPC tests (they exercise the envelope and the untouched prompt handler); the normalizeInput / buildInputForSurface tests in input-normalizer.test.ts (the tool-branch path is unchanged); permission-resolver.test.ts (the unwrap site, now load-bearing for two more consumers).

Invariants at risk

This change touches surfaces #478, #486, and #366 refactored.

  • #486 / #478 — the resolver is the sole path-values producer; the manager stays string-based and never imports AccessPath. Preserved and advanced: the service/RPC now emit access-path to the resolver (not path-values to the manager). Pinned by test/permission-resolver.test.ts (the unwrap) plus the new service/RPC tests asserting an access-path (not path-values) intent reaches resolve.
  • #478 — manager/resolver each expose a single resolution method. Preserved: no new resolution method; the service routes through resolve / getToolPermission.
  • #366 — narrow service collaborators. Preserved/advanced: the resolution surface collapses from manager + SessionRules to one resolver. Pinned by test/permissions-service.test.ts.
  • Value-less / missing-path ["*"] fallback. Preserved by the getNonEmptyString guard routing value-less path queries through the tool intent. Pinned by a buildAccessIntentForSurface test and a service value-less-query test.

No #438 session-approval invariant is at risk: the service/RPC are query-only (no prompting / approval-pattern derivation).

TDD Order

  1. feat(pi-permission-system)!: match the canonical form on service path queries Test surface: test/input-normalizer.test.ts + test/permissions-service.test.ts + test/service.test.ts + test/composition-root.test.ts. Add buildAccessIntentForSurface to input-normalizer.ts; migrate LocalPermissionsService onto (resolver, session, …) using it; move the resolver construction up in index.ts and change the LocalPermissionsService call site. The helper's first consumer is the service, so it lands non-dead. The constructor change has a single production call site (index.ts), so the class change + call-site update land together. Red: a service external_directory query for a symlinked path now matches a deny on its canonical target; a read query now evaluates the supplied path (not *); the bash/non-path queries stay on tool. Breaking — feat!: with a BREAKING CHANGE: footer (service path queries now match canonical; path-bearing queries now evaluate the path). Run pnpm run check after this commit (constructor + interface change). The RPC still uses its old { permissionManager, sessionRules, … } deps here — a valid green state.

  2. feat(pi-permission-system)!: match the canonical form on the RPC check query Test surface: test/permission-event-rpc.test.ts. Change PermissionRpcDeps (drop permissionManager + sessionRules, add resolver, extend session with getPathNormalizer); migrate handleCheckRpc onto buildAccessIntentForSurface + resolver.resolve; update the RPC deps in index.ts (the resolver is already constructed above from Step 1). The deps change + makeDeps fixture + index.ts call site break together (one commit). Red: a permissions:rpc:check query for a symlinked external_directory path now matches the canonical alias; allow/deny/reply-shape tests stay green against the resolver. Breaking — feat!: with a BREAKING CHANGE: footer (RPC check now matches canonical).

  3. docs(pi-permission-system): document canonical service/RPC path matching Update docs/architecture/architecture.md (mark Step 2 + S2 node; permissions-service.ts / permission-event-rpc.ts entries; intro framing + residual bullet to past tense), docs/cross-extension-api.md (checkPermission + RPC-check canonical note + path-bearing-query fix), .pi/skills/package-pi-permission-system/SKILL.md, and the access-intent.ts doc comment per Module-Level Changes. No release impact on its own — rides the breaking feat!: commits.

Risks and Mitigations

  • Risk: the resolver-injection rewiring is broader than "swap the intent." Mitigation: it is a clean 1:1 substitution — resolver.resolve(intent) does exactly what manager.check(toResolvedIntent(intent), sessionRules.getRuleset()) did, plus the access-path unwrap; the resolver subsumes the dropped SessionRules dependency. The only ordering change is moving one const resolver = … up, with no new dependency for the resolver.
  • Risk: the published service answers against the wrong cwd. Mitigation: getPathNormalizer() is fetched per call and the service is published only by the parent (#302); a composition-root.test.ts path query exercises the cwd-bound normalizer after session_start.
  • Risk: the latent path-bearing-query fix surprises a consumer relying on the old * collapse. Mitigation: this is the intended breaking behavior; documented in the BREAKING CHANGE: footer, the cross-extension API doc, and the close comment. The change only widens the match set (least-privilege preserving).
  • Risk: an import cycle from input-normalizer.ts importing PathNormalizer / AccessIntent. Mitigation: both are import type, and neither path-normalizer.ts nor access-intent/access-intent.ts imports input-normalizer.ts; pnpm run check confirms.
  • Risk: a stale fallow suppression surfaces (as in #502). Mitigation: run pnpm fallow dead-code after the source steps; the baseline check/lint/test triad does not catch a now-stale suppression.

Open Questions

  • None blocking. Step 3 (#504) removes the now-callerless input-normalizer path branches; Step 5 (#506) decides the path-values boundary — both already filed, both unblocked by this work. No new follow-up issue is needed (the latent path-bearing-query gap is fixed inline, not deferred).