22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 502 | pi-permission-system: migrate the per-tool path-bearing gate onto AccessPath (Phase 7 Step 1) |
Migrate the per-tool path-bearing gate onto AccessPath
Release Recommendation
Release: mid-batch — defer (batch "symlink-resistant-path-matching"); confirm at ship time
This is Phase 7 Step 1 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 1, not the tail, so it lands on main and auto-batches; the major-bump release cuts when Step 3 lands.
The breaking feat!: commit does not cut a release on its own here because the batch is still mid-flight — confirm the deferral at ship time.
Problem Statement
The per-tool path-bearing gate (read / write / edit / grep / find / ls) still matches lexically only, while the cross-cutting path surface matches the lexical aliases ∪ canonical (symlink-resolved) form after #486.
The per-tool gate's sixth gate producer in tool-call-gate-pipeline.ts emits a kind: "tool" intent, which the manager normalizes via normalizeInput → normalizePathSurfaceValues → getPathPolicyValues — lexical only.
So a per-tool rule like read: deny *.env can be evaded through a symlink alias, whereas the same rule on path cannot.
This asymmetry is the residual gap Phase 7 Step 1 closes: route the per-tool path-bearing gate onto AccessPath so its match set becomes lexical ∪ canonical — the same set #486 already gives path and external_directory.
Goals
- For path-bearing built-in tools (
read/write/edit/grep/find/ls), build anAccessPathvia the sessionPathNormalizerand emit akind: "access-path"intent withsurface: toolName, so the per-tool gate matches the lexical aliases ∪ canonical form. - Keep non-path tools (bash, MCP, extension tools, and a path-bearing tool with no
input.path) on the existingkind: "tool"intent — no behavior change for them. - Derive the per-tool session-approval suggestion value from
accessPath.value()instead of re-deriving it withnormalizePathForComparison, dropping theplatformparameter thatdescribeToolGatethreaded only to feed that derivation. - Remove the now-unused
getPlatform()session accessor (this resolves #513): with #511 already landed, this issue is the second of the two consumers to fold, so after the per-tool gate read goes,ToolCallGatePipeline.evaluate'sgetPlatform()read — and the accessor it backs — has no caller and must be removed (thepnpm fallow dead-codeCI gate would otherwise flag it).
This is a breaking change: adding the canonical alias to the per-tool match set alters which rules fire on upgrade with no user edit.
A symlink whose resolved target matches a per-tool deny/allow pattern now matches it where it previously did not.
The behavior step's commit is feat(pi-permission-system)!: with a BREAKING CHANGE: footer.
Non-Goals
- No migration of the service/RPC path queries onto
AccessPath— that is Phase 7 Step 2 (#503). - No removal of
input-normalizer'snormalizePathSurfaceValues/PATH_BEARING_TOOLSbranch — that is Phase 7 Step 3 (#504), after both Step 1 and Step 2 strip its callers. This plan leavesnormalizeInputintact; the path-bearing tool with noinput.pathstill routes through thetoolbranch (so the missing-path["*"]fallback is preserved). - No change to MCP or extension per-tool gating: they stay on the
toolintent (their path is already covered symlink-resistantly by the cross-cuttingpathgate, which emitsaccess-pathsince #486). - No change to dedup/approval-key identity: keys continue to derive from the lexical form (
accessPath.value()), so existing session approvals stay stable. - No principal identity on
AccessIntent; cross-session path portability stays deferred.
Background
Relevant modules (all in packages/pi-permission-system/):
src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGatePipeline.evaluateassembles six gate producers. The sixth producer is the per-tool gate: for bash it callsresolveBashCommandCheck, otherwise it resolves{ kind: "tool", surface: tcc.toolName, input: tcc.input }and feeds the result intodescribeToolGate. It currently readsconst platform = this.inputs.getPlatform()and threads it only intodescribeToolGate(after #511 removed the skill-read and external-directoryplatformthreading).src/handlers/gates/tool.ts—describeToolGate(tcc, check, formatter, platform)builds the descriptor;deriveSuggestionValuereturns the session-approval suggestion (bash→ command,mcp→ target, path-bearing →normalizePathForComparison(path, tcc.cwd, platform), else*).src/handlers/gates/path.ts— the cross-cuttingpathgate, already migrated by #486: buildsnormalizer.forPath(filePath), emits{ kind: "access-path", surface: "path", path }, derives the approval pattern fromaccessPath.value(). This is the template for the per-tool change.src/path-normalizer.ts—PathNormalizer.forPath(pathValue, options?)builds anAccessPathresolved against the baked sessioncwd+platform.src/path-utils.ts—getPathBearingToolPath(toolName, input)returnsinput.pathfor the six built-inPATH_BEARING_TOOLS, elsenull;PATH_SURFACESincludes the path-bearing tool names, so the manager'sbuildCheckResultalready routes asurface: "read"path-values intent throughevaluateAnyValue(last-match-wins across aliases).src/permission-resolver.ts—toResolvedIntentunwraps anaccess-pathintent topath-valuesviapath.matchValues(); the manager stays string-based and never importsAccessPath.src/access-intent/access-intent.ts—AccessPathAccessIntent; its doc comment names only thepathandexternal_directorysurfaces as emitters.src/permission-session.ts—getPlatform()returnsthis.platform(still used internally bygetPathNormalizer()); the accessor is consumed only by the pipeline'sgetPlatform()read.
getPlatform() consumers (grep-verified): PermissionSession.getPlatform() (definition), ToolCallGateInputs.getPlatform() (interface member), and the single const platform = this.inputs.getPlatform() read in ToolCallGatePipeline.evaluate.
The test fixture makeGateInputs (test/helpers/gate-fixtures.ts) provides a getPlatform stub.
No other src/ or test/ reader exists.
Key constraint (AGENTS.md / SKILL): the manager stays string-based and never imports AccessPath; the resolver does the matchValues() unwrap.
This change preserves that — the per-tool gate emits access-path, the resolver unwraps, the manager is untouched (mechanically parallel to #486).
#513 designates "whichever of [#502]/#511 lands second drops the accessor." #511 is already merged, so [#502] is second and folds the removal in; #513 closes when this ships.
Design Overview
The match set is already single-sourced
AccessPath.matchValues() returns exactly lexical aliases ∪ canonical.
The resolver already unwraps an access-path intent through matchValues(), and PATH_SURFACES.has("read") is true, so the manager already evaluates a surface: "read" path-values intent with evaluateAnyValue.
So the change is purely: make the per-tool gate emit access-path (for path-bearing tools with a path) instead of tool.
No manager or resolver change is needed — the only behavior change is the canonical alias joining the match set.
Per-tool gate producer (tool-call-gate-pipeline.ts)
The sixth producer gains an AccessPath branch for path-bearing tools, keyed off getPathBearingToolPath (which is non-null only for the six built-ins with a present input.path):
const path = getPathBearingToolPath(tcc.toolName, tcc.input);
let accessPath: AccessPath | undefined;
let toolCheck: PermissionCheckResult;
if (tcc.toolName === "bash" && bashProgram) {
toolCheck = resolveBashCommandCheck(command ?? "", bashProgram.commands(), tcc.agentName ?? undefined, this.resolver);
} else if (path !== null) {
accessPath = normalizer.forPath(path);
toolCheck = this.resolver.resolve({
kind: "access-path",
surface: tcc.toolName,
path: accessPath,
agentName: tcc.agentName ?? undefined,
});
} else {
toolCheck = this.resolver.resolve({
kind: "tool",
surface: tcc.toolName,
input: tcc.input,
agentName: tcc.agentName ?? undefined,
});
}
const toolDescriptor = describeToolGate(tcc, toolCheck, formatter, accessPath);
toolDescriptor.preCheck = toolCheck;
return toolDescriptor;
normalizer is the PathNormalizer already obtained at the top of evaluate; the const platform = this.inputs.getPlatform() read and its threading into describeToolGate are removed. (prefer-const does not fire here: accessPath/toolCheck are each assigned once across the branches, but a let with no initializer assigned in mutually exclusive branches is fine; if the linter objects, hoist the branch into a small helper that returns { accessPath, toolCheck }.)
The discriminator path !== null preserves the missing-path case: a path-bearing tool whose input.path is absent routes through the tool intent, where normalizeInput collapses it to ["*"] exactly as today.
Per-tool descriptor (tool.ts)
describeToolGate takes an optional accessPath in place of platform; deriveSuggestionValue reads accessPath.value() for the path branch:
function deriveSuggestionValue(
tcc: ToolCallContext,
check: PermissionCheckResult,
accessPath?: AccessPath,
): string {
if (tcc.toolName === "bash") return check.command ?? "";
if (tcc.toolName === "mcp") return check.target ?? "mcp";
if (accessPath) return accessPath.value();
return "*";
}
export function describeToolGate(
tcc: ToolCallContext,
check: PermissionCheckResult,
formatter: ToolPreviewFormatter,
accessPath?: AccessPath,
): GateDescriptor { /* ... */ }
accessPath.value() is behavior-identical to today's normalizePathForComparison(path, tcc.cwd, platform): value() returns normalizePathForComparison(pathValue, resolveBase = cwd, platform), and the pipeline's normalizer is built from the same session cwd + platform as tcc.cwd.
The normalizePathForComparison import is removed from tool.ts.
decision.value is unchanged — it keeps using getPathBearingToolPath(tcc.toolName, tcc.input) (the raw referenced path) via deriveDecisionValue, so getPathBearingToolPath and PATH_BEARING_TOOLS imports stay.
This honors Tell-Don't-Ask (the descriptor asks the AccessPath for value()) and removes a parameter relay: platform was threaded session → pipeline → describeToolGate solely to feed one derivation that the AccessPath the gate already builds now owns.
Remove the dead getPlatform() accessor (resolves #513)
After the per-tool gate stops reading platform, ToolCallGatePipeline.evaluate no longer calls getPlatform(), and the accessor is dead.
Remove getPlatform() from ToolCallGateInputs (the pipeline's narrow input interface) and from PermissionSession, and drop the getPlatform field from makeGateInputs.
this.platform stays on PermissionSession (still feeds getPathNormalizer()); only the session-level accessor that existed to feed the residual reads is retired.
Edge cases
- Missing
input.path:getPathBearingToolPathreturnsnull→toolintent →["*"](preserved). - Not a symlink:
matchValues()collapses to the lexical aliases when canonical equals one of them — no spurious extra value. - Unresolvable path (ELOOP / EACCES / empty):
canonicalNormalizePathForComparisonfalls back to the lexical form; no new match beyond today's lexical behavior. - MCP / extension tools: stay on
tool(no change); their path is gated symlink-resistantly by the cross-cuttingpathgate.
Module-Level Changes
Source:
src/handlers/gates/tool-call-gate-pipeline.ts— sixth producer builds anAccessPathand emitsaccess-pathfor path-bearing tools (elsetool); passaccessPathtodescribeToolGate; remove theconst platform = this.inputs.getPlatform()read and its arg. Add imports:getPathBearingToolPath(#src/path-utils) and theAccessPathtype (#src/access-intent/access-path).src/handlers/gates/tool.ts—describeToolGateandderiveSuggestionValuetake an optionalaccessPathin place ofplatform; suggestion path branch readsaccessPath.value(); remove thenormalizePathForComparisonimport; add anAccessPathtype import.src/handlers/gates/tool-call-gate-pipeline.ts(interface) — removegetPlatform()fromToolCallGateInputsand its doc comment.src/permission-session.ts— remove thegetPlatform()method (keep the privateplatformfield).src/access-intent/access-intent.ts— update theAccessPathAccessIntentdoc comment: emitters now include the per-tool path-bearing surfaces (read/write/edit/grep/find/ls), not justpathandexternal_directory.
Tests:
test/handlers/gates/tool.test.ts— drop the"linux"fourth argument from everydescribeToolGatecall; for the two session-approval cwd tests, build anAccessPathvianew PathNormalizer("linux", "/test/project").forPath(...)and pass it; non-path / bash / mcp calls passundefined; add a case asserting the suggestion derives fromaccessPath.value().test/handlers/gates/tool-call-gate-pipeline.test.ts— add a test that a path-bearing tool withinput.pathemits anaccess-pathintent on its tool-name surface (assert via a resolver mock dispatching onintent.kind/intent.surface); add a test that a per-tooldenymatched against a symlinked path's canonical alias blocks; confirm non-path tools still emittool.test/helpers/gate-fixtures.ts— remove thegetPlatformoverride field and default frommakeGateInputs.
Documentation (grep-verified — symbol/behavior named in prose):
docs/architecture/architecture.md— mark Phase 7 Step 1 ([#502]) complete (✅on the step heading and theS1Mermaid node); update theaccess-intent.tsmodule entry (line ~678, per-tool surfaces now emitaccess-path) and thetool.tsentry (line ~712, approval value fromaccessPath.value(), noplatform); rewrite the residual-getPlatform()-threading subsection (lines ~880–892) to record that both #511 and [#502] have landed, sogetPlatform()is removed and #513 is resolved. Leave the Phase-7 health-metric/target table unchanged (its rows describe the phase endpoint, not a per-step state).docs/configuration.md— the per-tool patterns section (lines ~356, ~404–407, and the per-tool surface description): state that per-tool path rules now also match the canonical (symlink-resolved) form, at parity withpath(generalize the existing "Symlinked paths" note ~469–483 to name the per-tool surfaces).README.md— the per-tool path-patterns description (lines ~74–75): add that per-tool path rules now resist symlink-alias evasion like the cross-cuttingpathrule..pi/skills/package-pi-permission-system/SKILL.md— update line ~127 (getPathBearingToolPathnow also builds the per-tool gate'sAccessPath, no longer only "cosmetic"), lines ~150–153 (the per-tool gate emitsaccess-pathon its tool-name surface; themakeHandleradapter still mapspath-values→surfaceCheck(surface, { path: values[0] })), and line ~174 (drop the/ getPlatform()mention from thePathNormalizerexposure note).
Test Impact Analysis
- New tests the change enables:
- The per-tool gate denying/asking a symlink whose canonical target matches a per-tool
denypattern (e.g.read: deny *.envreached via a symlink) — testable at the pipeline level with an intent-kind-dispatching resolver mock. describeToolGatederiving the session-approval suggestion from an injectedAccessPath— a more direct unit than the priorinput+cwd+platformderivation.
- The per-tool gate denying/asking a symlink whose canonical target matches a per-tool
- Tests that become redundant: none removed; existing lexical-match and cwd-bounding assertions stay valid (lexical aliases are still in
matchValues(), andaccessPath.value()equals the oldnormalizePathForComparisonresult). - Tests that must stay as-is: the #438 session-approval cwd-bounding tests in
tool.test.ts("binds a current-directory file's session approval to the cwd subtree", "resolves a sub-directory file's session approval to an absolute pattern") — they now pass anAccessPathbut must keep asserting/test/project/*and/test/project/src/*; the bash/mcp suggestion and decision-value tests.
Invariants at risk
This change touches surfaces #486, #438, and #510 refactored. Documented invariants and their pinning tests:
- #486
path/external_directorymatch lexical ∪ canonical — extended (not regressed) to the per-tool surfaces;path.tsis untouched. Pinned bytest/handlers/gates/path.test.tsandtest/access-intent/access-path.test.ts. - #438 session approval is cwd-bounded (absolute, not
./*) — preserved:accessPath.value()equals the oldnormalizePathForComparison(path, cwd, platform). Pinned by the two cwd-bounding tests intest/handlers/gates/tool.test.ts— keep them green with theAccessPathargument. - #510
PathNormalizeris the single platform/cwd home — preserved: the per-tool gate now routes its path throughnormalizer.forPath, removing the lastplatformthread into the gate layer. - Missing-path
["*"]fallback — preserved by routing the no-input.pathcase through thetoolintent; add/keep a pipeline test for a path-bearing tool with empty input.
TDD Order
-
feat(pi-permission-system)!: match the canonical form on the per-tool path gateTest surface:test/handlers/gates/tool.test.ts+test/handlers/gates/tool-call-gate-pipeline.test.ts. Migrate the sixth producer intool-call-gate-pipeline.tsto emitaccess-pathfor path-bearing tools and pass theAccessPathtodescribeToolGate; changedescribeToolGate/deriveSuggestionValueto take the optionalaccessPathand readvalue(); remove theplatformparam, thenormalizePathForComparisonimport, and the pipeline'sgetPlatform()read. These break together at the type level (thedescribeToolGatesignature change has a single call site and the test file), so they land in one commit. Red: aread/editon a symlink whose canonical target matches a per-tooldenyis now blocked; the cwd-bounding approval tests stay green via the injectedAccessPath. Breaking —feat!:with aBREAKING CHANGE:footer.ToolCallGateInputs.getPlatform()/PermissionSession.getPlatform()/makeGateInputs.getPlatformremain (defined but uncalled — a valid green state); runpnpm run checkafter this commit. -
refactor(pi-permission-system): remove the unused getPlatform session accessorTest surface: type-level +makeGateInputs. RemovegetPlatform()fromToolCallGateInputsandPermissionSession, and thegetPlatformfield frommakeGateInputs. These break together (excess-property on the fixture literal once the interface drops the member), so one commit.tsc+pnpm fallow dead-codeconfirm no remaining consumer. Resolves #513 (close it at ship with a "folded into #502" note). -
docs(pi-permission-system): document canonical per-tool path matchingUpdatedocs/architecture/architecture.md(mark Step 1 ✅ +S1node ✅; module entries; residual-getPlatform()subsection),docs/configuration.md,README.md,.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!:.
Risks and Mitigations
- Risk: the suggestion value silently drifts from the policy values.
Mitigation:
accessPath.value()is provably identical to the oldnormalizePathForComparison(path, cwd, platform)(same cwd + platform via the session normalizer); the #438 cwd-bounding tests pin it. - Risk: removing
getPlatform()breaks an unseen consumer. Mitigation: grep confirms exactly threesrc/references and one fixture;tsc+fallow dead-codegate the removal. - Risk: a
prefer-const/no-unused-varslint snag on the new branch structure. Mitigation: if the two-letform trips a linter, extract a smallselectToolCheckhelper returning{ accessPath, toolCheck }(Code Design stepdown). - Risk: an existing user's per-tool rule starts matching a previously-unmatched symlinked path on upgrade.
This is the intended breaking behavior; mitigation is the
BREAKING CHANGE:footer and the docs update describing the new symlink-resistant per-tool matching.
Open Questions
- Close #513 when this ships.
Its scope (the
getPlatform()removal) is folded into Step 2 here; no separate change remains, so close it at ship time with a pointer to the [#502] SHA. - No other blocking questions. The residual Phase 7 scope (Steps 2–5) is unaffected; this plan only completes Step 1 and the #513 cleanup it forces.