28 KiB
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 normalizeInput → normalizePathSurfaceValues → getPathPolicyValues.
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 (thePATH_SURFACESset) with a non-empty value, build anAccessPathfrom the value and emit akind: "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 unwrapsaccess-path→path-valuesviamatchValues(), keeping the manager string-based. This makes the resolver the solepath-valuesproducer, 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 +
SessionRulespair (it composes the session ruleset internally), so each consumer holds one resolution collaborator plus the session'sPathNormalizer.
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:
- An
external_directoryquery for a symlinked path now matches a rule on its canonical target where it previously matched lexically only. - A
pathor 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'snormalizePathSurfaceValues/ special-surface /PATH_BEARING_TOOLSbranches — that is Phase 7 Step 3 (#504), after Steps 1 and 2 strip their last gate/service/RPC callers. This plan leavesnormalizeInputintact; the per-tool gate's missing-path case still routes through it for the["*"]fallback. - No decision on the
path-valuesboundary (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.tsderivation 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.ts—LocalPermissionsService.checkPermission(surface, value, agentName)buildsbuildInputForSurface(surface, value)and callspermissionManager.check({ kind: "tool", surface, input, agentName }, sessionRules.getRuleset()).getToolPermissiondelegates topermissionManager.getToolPermission. Constructed inindex.tswith(permissionManager, sessionRules, formatterRegistry, accessExtractorRegistry)(narrowed in #366).src/permission-event-rpc.ts—handleCheckRpcbuildsbuildInputForSurface(surface, value)and callsdeps.permissionManager.check({ kind: "tool", ... }, deps.sessionRules.getRuleset()). The deprecated channel (/* eslint-disable @typescript-eslint/no-deprecated */at the top).handlePromptRpcusesvalueonly for the UI prompt display; it never resolves policy.src/input-normalizer.ts—buildInputForSurface(surface, value)is the inverse ofnormalizeInput: it builds the minimaltool-intent input from a(surface, value)pair. It mapsbash→{ command },skill→{ name },external_directory→{ path }, and everything else (includingpathand the path-bearing tools) →{}— so the value is dropped for those surfaces.src/permission-resolver.ts—PermissionResolver.resolve(intent: AccessIntent)reduces a gate-emitted intent to the manager'sResolvedAccessIntent, unwrappingaccess-path→path-valuesviapath.matchValues(), and composessessionRules.getRuleset()internally so callers never thread it. Also exposesgetToolPermission,getConfigIssues,checkPermission(the no-session-rules skill path). Constructed inindex.tsasnew PermissionResolver(permissionManager, sessionRules)— after the service and RPC today (line ~177 vs. ~145 / ~137); the move-up is mechanical (it depends only onpermissionManager+sessionRules).src/permission-session.ts—getPathNormalizer(): PathNormalizerreturns the session's normalizer, rebuilt on eachactivate(ctx)/resetForNewSessionso it tracks the active cwd. A placeholder (new PathNormalizer(platform, "")) until the firstactivatebinds the real cwd.src/path-normalizer.ts—PathNormalizer.forPath(value)builds anAccessPathresolved against the baked sessioncwd+platform.src/access-intent/access-intent.ts—AccessPathAccessIntent(kind: "access-path"); its doc comment names the gate emitters (thepath/external_directory/per-tool surfaces).src/path-utils.ts—PATH_SURFACES = PATH_BEARING_TOOLS ∪ { "external_directory", "path" }.src/value-guards.ts—getNonEmptyString(value): string | null(trims;nullfor 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 thematchValues()unwrap. This plan preserves that — the service/RPC emitaccess-pathto 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/denybecomesallow.
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:
- Have the service/RPC build the
AccessPath, callmatchValues()themselves, and pass apath-valuesintent tomanager.check. - Have the service/RPC emit an
access-pathintent toresolver.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 beforeregisterPermissionRpcHandlers(the only ordering change; downstream consumers reference the same const). - RPC deps:
{ resolver, session, requestPermissionDecisionFromUi, logger }(the fullPermissionSessionsatisfies both narrowsessionneeds). 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:
getNonEmptyString→null→toolintent →["*"](preserved). - Non-path surface (bash/skill/mcp/extension):
toolintent viabuildInputForSurface(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— addbuildAccessIntentForSurface(path-surface →access-path, elsetool); keepbuildInputForSurfaceexported as thetool-branch input builder. Add imports:PATH_SURFACES(#src/path-utils),getNonEmptyStringis already imported, type importsAccessIntent(#src/access-intent/access-intent) andPathNormalizer(#src/path-normalizer).src/permissions-service.ts— constructor takes(resolver, session, formatterRegistry, accessExtractorRegistry);checkPermissionbuilds the intent viabuildAccessIntentForSurfaceand callsresolver.resolve;getToolPermissiondelegates toresolver.getToolPermission. Define localResolverForService+PathNormalizerProviderinterfaces; drop theScopedPermissionManager/SessionRules/buildInputForSurfaceimports, addbuildAccessIntentForSurface,AccessIntent,PathNormalizer,PermissionStatetype imports.src/permission-event-rpc.ts—PermissionRpcDepsdropspermissionManager+sessionRules, addsresolver: Pick<ScopedPermissionResolver, "resolve">, extendssessionwithgetPathNormalizer;handleCheckRpcbuilds the intent and callsdeps.resolver.resolve. Swap theScopedPermissionManagerimport forScopedPermissionResolver, dropbuildInputForSurface, addbuildAccessIntentForSurfaceand aPathNormalizertype import.src/index.ts— move theresolverconstruction above the RPC registration; passresolver+sessioninto the RPC deps andLocalPermissionsService.src/access-intent/access-intent.ts— update theAccessPathAccessIntentdoc comment: emitters now also include the service/RPC path queries (not only the gates).
Tests:
test/input-normalizer.test.ts— add abuildAccessIntentForSurfacedescribe: aPATH_SURFACESsurface (path,external_directory,read) with a value emitsaccess-pathwhosepath.matchValues()carries the canonical alias (use thenode:fsrealpathSyncmock convention frompath.test.tsfor the symlink case); a non-path surface (bash) emitstoolwithbuildInputForSurfaceinput; an empty/whitespace value on a path surface emitstool(["*"]path via the manager).test/permissions-service.test.ts— rewrite to inject a fake resolver (resolve+getToolPermissionstubs) and a realPathNormalizerprovider; assertcheckPermission("bash", "echo hi")callsresolver.resolvewith atoolintent;checkPermission("external_directory", "/sym/link")andcheckPermission("read", "/p")call it with anaccess-pathintent whosepathmatches the expected lexical ∪ canonical set; a value-less path query falls totool;getToolPermissiondelegates toresolver.getToolPermission. Drop thevi.mock("#src/input-normalizer")buildInputForSurfacestub.test/permission-event-rpc.test.ts—makeDepsswapspermissionManager+sessionRulesforresolver: { resolve: vi.fn() }and addssession.getPathNormalizer; the existing allow/deny check-RPC tests assert onresolver.resolveinstead ofpermissionManager.check; add a path-surface RPC test asserting anaccess-pathintent reaches the resolver (canonical alias in the match set).test/service.test.ts— update the "service adapter delegation" describe: replace the hand-rolledbuildInputForSurfaceadapter (which simulated the oldindex.tswiring) with the currentbuildAccessIntentForSurface+ resolver pattern, so the round-trip tests document the new wiring; the stalecheckPermission("read", "/tmp/file") → {}assertion is replaced by anaccess-path-intent assertion.test/composition-root.test.ts— the existingcheckPermission("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 firessession_start, sogetPathNormalizer()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 theS2Mermaid node ~line 835); update thepermissions-service.tsentry (~line 716) andpermission-event-rpc.tsentry (~line 720) to note they route path-surface queries through the resolver asaccess-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— thecheckPermissionsection (~lines 81–94) and the RPCpermissions:rpc:checksection (~line 444): add thatpath/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 theLocalPermissionsServicenote (~line 118) to record that path-surface service/RPC queries route through the resolver asaccess-path(canonical parity), fetching the sessionPathNormalizerper 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
- New tests the change enables:
buildAccessIntentForSurfaceas a directly unit-testable surface→intent mapping (no manager round-trip needed) — the symlink-canonical match set is asserted on the builtAccessPath.- A service/RPC path query whose canonical alias matches a
deny(e.g.external_directoryreached via a symlink) — assertable with a fake resolver dispatching onintent.kind/intent.surface. - A
read/pathquery now evaluates the supplied path (the latent-gap fix), replacing the old→ {}drop assertion.
- Tests that become redundant: the
test/service.test.tshand-rolled-adapter tests that simulated the oldbuildInputForSurfaceindex.ts wiring lose their reason to exist as wiring docs — folded into the rewritten "service adapter delegation" block (current wiring) andpermissions-service.test.ts(real class). No test is deleted outright. - Tests that must stay as-is: the existing
permission-event-rpc.test.tsreply-shape / error-path / prompt-RPC tests (they exercise the envelope and the untouched prompt handler); thenormalizeInput/buildInputForSurfacetests ininput-normalizer.test.ts(thetool-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-valuesproducer; the manager stays string-based and never importsAccessPath. Preserved and advanced: the service/RPC now emitaccess-pathto the resolver (notpath-valuesto the manager). Pinned bytest/permission-resolver.test.ts(the unwrap) plus the new service/RPC tests asserting anaccess-path(notpath-values) intent reachesresolve. - #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 +
SessionRulesto one resolver. Pinned bytest/permissions-service.test.ts. - Value-less / missing-path
["*"]fallback. Preserved by thegetNonEmptyStringguard routing value-less path queries through thetoolintent. Pinned by abuildAccessIntentForSurfacetest 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
-
feat(pi-permission-system)!: match the canonical form on service path queriesTest surface:test/input-normalizer.test.ts+test/permissions-service.test.ts+test/service.test.ts+test/composition-root.test.ts. AddbuildAccessIntentForSurfacetoinput-normalizer.ts; migrateLocalPermissionsServiceonto(resolver, session, …)using it; move theresolverconstruction up inindex.tsand change theLocalPermissionsServicecall 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 serviceexternal_directoryquery for a symlinked path now matches adenyon its canonical target; areadquery now evaluates the supplied path (not*); the bash/non-path queries stay ontool. Breaking —feat!:with aBREAKING CHANGE:footer (service path queries now match canonical; path-bearing queries now evaluate the path). Runpnpm run checkafter this commit (constructor + interface change). The RPC still uses its old{ permissionManager, sessionRules, … }deps here — a valid green state. -
feat(pi-permission-system)!: match the canonical form on the RPC check queryTest surface:test/permission-event-rpc.test.ts. ChangePermissionRpcDeps(droppermissionManager+sessionRules, addresolver, extendsessionwithgetPathNormalizer); migratehandleCheckRpcontobuildAccessIntentForSurface+resolver.resolve; update the RPC deps inindex.ts(theresolveris already constructed above from Step 1). The deps change +makeDepsfixture +index.tscall site break together (one commit). Red: apermissions:rpc:checkquery for a symlinkedexternal_directorypath now matches the canonical alias; allow/deny/reply-shape tests stay green against the resolver. Breaking —feat!:with aBREAKING CHANGE:footer (RPC check now matches canonical). -
docs(pi-permission-system): document canonical service/RPC path matchingUpdatedocs/architecture/architecture.md(mark Step 2 ✅ +S2node;permissions-service.ts/permission-event-rpc.tsentries; 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 theaccess-intent.tsdoc comment per Module-Level Changes. No release impact on its own — rides the breakingfeat!: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 whatmanager.check(toResolvedIntent(intent), sessionRules.getRuleset())did, plus theaccess-pathunwrap; the resolver subsumes the droppedSessionRulesdependency. The only ordering change is moving oneconst 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); acomposition-root.test.tspath query exercises the cwd-bound normalizer aftersession_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 theBREAKING 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.tsimportingPathNormalizer/AccessIntent. Mitigation: both areimport type, and neitherpath-normalizer.tsnoraccess-intent/access-intent.tsimportsinput-normalizer.ts;pnpm run checkconfirms. - Risk: a stale fallow suppression surfaces (as in #502).
Mitigation: run
pnpm fallow dead-codeafter the source steps; the baseline check/lint/test triad does not catch a now-stale suppression.