20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 486 | pi-permission-system: should the path surface match the canonical (symlink-resolved) form like external_directory? |
Make the path surface match the canonical (symlink-resolved) form
Release Recommendation
Release: ship independently
Issue #486 is a follow-on filed and deferred during Phase 6 (#478); it is not a member of any active roadmap batch, and Phase 6 is closed. It is a self-contained behavior change to one surface, so it ships on its own — and because it is breaking (see Goals), it warrants its own major-bump release rather than batching.
Problem Statement
The path and external_directory surfaces match against different value sets today:
pathmatches the lexical aliases only — the as-typed form and its cwd/effective-base absolute resolution (getPathPolicyValues).external_directorymatches the lexical aliases plus the canonical (symlink-resolved) form (AccessPath.matchValues()), the #418 fix: a rule keyed on/tmp/*matches even when the access resolves to/private/tmp.
This asymmetry means a path deny on a sensitive spelling (*.env, ~/.ssh/*) can be evaded through a symlink alias, whereas the same rule on external_directory cannot.
The operator has decided (issue thread) that path should also match the canonical form, so a path deny on /etc/passwd catches a symlink to it.
After this change the two surfaces match the identical value set and the asymmetry dissolves.
Goals
- Make the
pathsurface match the lexical aliases plus the canonical (symlink-resolved) form — the same setAccessPath.matchValues()already computes — across both producers (the tool path gate and the bash-path gate), so no new tool-vs-bash asymmetry is introduced. - Route both
pathproducers throughAccessPath(the "full" scope the operator chose), pulling the bash-pathAccessPathmigration forward from #487. - Collapse the now-unproduced emitted
path-valuesAccessIntentvariant, completing one item the #487 direction listed. - Preserve the #393 unknown-base behavior (a relative bash token after a non-literal
cdkeeps its literal value only — no canonical, no spurious absolute) and the #418 / #382 external-directory invariants.
This is a breaking change: adding the canonical alias to the path match set alters which rules fire on upgrade with no user edit.
A symlink whose resolved target matches a path deny (or allow) pattern now matches it where it previously did not.
The suggested commit for the behavior step is feat(pi-permission-system)!: with a BREAKING CHANGE: footer.
Non-Goals
- No migration of config-pattern or prompt-input path handling onto
AccessPath— those remain the residual #487 scope after this change. - No change to what
external_directorymatches (it already matches lexical ∪ canonical); this change only bringspathto parity. - 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/access-intent/access-path.ts—AccessPathvalue object.matchValues()returns lexical aliases ∪ canonical;boundaryValue()the canonical form;value()the lexical absolute form. Built today via the private constructor throughforExternalDirectory(pathValue, cwd).src/access-intent/access-intent.ts— theAccessIntentemitted union (tool|path-values|access-path) and theResolvedAccessIntentmanager-consumed union (tool|path-values).src/permission-resolver.ts—toResolvedIntentunwraps anaccess-pathintent topath-valuesviamatchValues()before handing it to the manager; the manager stays string-based.src/permission-manager.ts—check(intent): thepath-valuesbranch evaluatesintent.valuesdirectly againstintent.surface; thetoolbranch normalizes raw input vianormalizeInput→normalizePathSurfaceValues→getPathPolicyValues(lexical only).src/handlers/gates/path.ts— tool path gate. Emits{ kind: "tool", surface: "path", input: { path } }; the manager normalizes it lexically.src/handlers/gates/bash-path.ts— bash path gate. Emits{ kind: "path-values", surface: "path", values }, the values coming fromBashProgram.pathRuleCandidates().src/access-intent/bash/cwd-projection.ts—projectRuleCandidatesbuildsBashPathRuleCandidate[]({ token, policyValues });getPolicyValuesForRuleCandidatereturns lexical values, and for an unknown base + relative candidate returns the literal only (#393).src/handlers/gates/external-directory.ts,src/access-intent/bash/cwd-projection.ts(projectExternalPaths) — the existingforExternalDirectorycallers (the external-directory surface).
Key constraint (AGENTS.md / SKILL): the manager stays string-based and never imports AccessPath; the resolver does the matchValues() unwrap.
This change preserves that — both path producers emit access-path, the resolver unwraps, the manager is untouched.
The reason the asymmetry exists is documented in docs/architecture/architecture.md (the access-path narrative) and in the #478 retro: forcing bash-path through AccessPath would inject a canonical alias the path surface did not match — a behavior change deferred to this issue.
That behavior change is now wanted.
Design Overview
The match set is already single-sourced
AccessPath.matchValues() returns exactly lexical aliases ∪ canonical — the set the path surface should now match.
The resolver already unwraps an access-path intent through matchValues().
So the change is: make both path producers emit access-path instead of their lexical-only forms.
No manager change is needed.
Factory: generalize forExternalDirectory to a surface-neutral forPath, add forLiteral
forExternalDirectory(pathValue, cwd) is no longer external-directory-specific.
Generalize and rename it to a surface-neutral factory that also supports a cd-resolved base for bash candidates:
// access-path.ts
static forPath(
pathValue: string,
options: { cwd: string; resolveBase?: string },
): AccessPath {
const { cwd, resolveBase = cwd } = options;
return new AccessPath(
normalizePathForComparison(pathValue, resolveBase),
getPathPolicyValues(pathValue, { cwd, resolveBase }),
canonicalNormalizePathForComparison(pathValue, resolveBase),
);
}
// literal-only: the #393 unknown-base case — no absolute, no canonical
static forLiteral(literal: string): AccessPath {
return new AccessPath(literal, literal ? [literal] : [], "");
}
forPath(p, { cwd }) (resolveBase defaults to cwd) is behavior-identical to the old forExternalDirectory(p, cwd): getPathPolicyValues(p, { cwd, resolveBase: cwd }) equals getPathPolicyValues(p, { cwd }) because resolveBase already defaults to cwd inside getAbsolutePathPolicyValues.
So renaming the external-directory callers preserves their behavior.
forLiteral produces matchValues() === [literal], boundaryValue() === "", value() === literal — exactly the conservative unknown-base shape (matchValues() already collapses to the aliases when canonical is "").
Tool path gate (path.ts)
Build an AccessPath and emit an access-path intent on the path surface; derive the approval pattern from accessPath.value() (the lexical absolute, identical to today's normalizePathForComparison(filePath, tcc.cwd)):
const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
const check = resolver.resolve({
kind: "access-path",
surface: "path",
path: accessPath,
agentName: tcc.agentName ?? undefined,
});
if (check.state === "allow") return null;
if (check.matchedPattern === undefined) return null; // #58 backward-compat guard, unchanged
const pattern = deriveApprovalPattern(accessPath.value());
The #58 guard (skip when only the universal default fired) is preserved — the resolved check still carries matchedPattern.
Prompt/log/decision/denialContext keep using the raw filePath.
Bash path candidates (cwd-projection.ts → bash-path.ts)
BashPathRuleCandidate carries an AccessPath instead of precomputed lexical policyValues, keeping the raw token for prompts/logs/approvals:
export interface BashPathRuleCandidate {
readonly token: string;
readonly path: AccessPath;
}
projectRuleCandidates builds each candidate's AccessPath, preserving the #393 unknown-base branch via forLiteral:
const path =
base.kind === "unknown" && isRelativeCandidate(candidate)
? AccessPath.forLiteral(normalizePathPolicyLiteral(candidate))
: AccessPath.forPath(candidate, {
cwd,
resolveBase: base.kind === "known" ? resolve(cwd, base.offset) : cwd,
});
if (path.matchValues().length === 0) continue;
const key = path.matchValues().join("\0"); // dedup identity preserved
getPolicyValuesForRuleCandidate is dissolved into projectRuleCandidates (its sole caller) — its lexical/literal branching moves into the AccessPath construction above.
bash-path.ts emits access-path per candidate and derives the approval base from path.value():
for (const { token, path } of candidates) {
const check = resolver.resolve({
kind: "access-path",
surface: "path",
path,
agentName: tcc.agentName ?? undefined,
});
// ...existing #58 guard, deny short-circuit, ask accumulation unchanged...
}
// approval base:
const approvalBase = worstEntry.path.value();
This honors Tell-Don't-Ask (the resolver asks the AccessPath for matchValues()) and keeps the manager string-based — identical to how the external-directory gates already work.
Collapse the emitted path-values variant
After both producers emit access-path, no gate emits path-values.
Remove PathValuesAccessIntent from the emitted AccessIntent union while keeping it in ResolvedAccessIntent (the resolver still produces it internally via toResolvedIntent, and the manager still consumes it):
export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
toResolvedIntent now maps access-path → path-values and tool → tool; its prior path-values passthrough case is gone.
Edge cases
- Not a symlink:
matchValues()already collapses to the lexical aliases when canonical equals one of them — no spurious extra value. - Unresolvable path (empty / ELOOP / EACCES):
canonicalNormalizePathForComparisonfalls back to the lexical form;forLiteralyieldsboundaryValue() === "". No new match introduced beyond today's lexical behavior. #58no-path-key configs: unchanged — thematchedPattern === undefinedguard still short-circuits.- Dedup / session approvals: keys derive from
value()(lexical), unchanged.
Module-Level Changes
src/access-intent/access-path.ts— rename/generalizeforExternalDirectory(pathValue, cwd)→forPath(pathValue, { cwd, resolveBase? }); addforLiteral(literal). Update the class doc comment (it namesforExternalDirectory).src/access-intent/access-intent.ts— removePathValuesAccessIntentfrom the emittedAccessIntentunion (keep the interface and its place inResolvedAccessIntent); update doc comments that describepath-valuesas an emitted variant.src/permission-resolver.ts—toResolvedIntentdrops thepath-valuespassthrough branch (nowtool | access-pathinput only).src/handlers/gates/path.ts— buildAccessPath.forPath, emitaccess-pathonpath, derive pattern fromaccessPath.value().src/handlers/gates/bash-path.ts— consume{ token, path }candidates, emitaccess-pathper candidate, deriveapprovalBasefrompath.value().src/access-intent/bash/cwd-projection.ts—BashPathRuleCandidatebecomes{ token, path: AccessPath };projectRuleCandidatesbuildsAccessPath(viaforPath/forLiteral); inline and removegetPolicyValuesForRuleCandidate. Rename the existingprojectExternalPathsforExternalDirectorycall sites (2) toforPath.src/access-intent/bash/program.ts— re-exportsBashPathRuleCandidate(shape change flows through; verify no other change needed).src/handlers/gates/external-directory.ts— rename theforExternalDirectorycall toforPath.
Documentation (grep-verified — symbol/behavior is named in prose):
docs/configuration.md—pathSurface section: update "matches as the agent references it" framing to state it now also matches the symlink-resolved form; add apath-surface symlink note (or generalize the existingexternal_directory"Symlinked paths" note at line ~465 to cover both surfaces).docs/architecture/architecture.md— update: theaccess-path.tsmodule entry (factory name/desc, line ~670), thebash-path.tsentry (line ~696, now emitsaccess-path), thecwd-projection.tsentry (pathRuleCandidates()returnsAccessPath-backed candidates), theaccess-intent.tsentry (emitted union no longer carriespath-values), and the Phase 6 follow-on note (line ~760: #486 implemented, #487 narrowed to config-pattern/prompt-input migration). Verify the inlineRule/Rulesettype listings are untouched (they are — no rule-type field changes here)..pi/skills/package-pi-permission-system/SKILL.md— update the gate-fixtures/intent notes (lines ~150–152): the bash path gate now emitsaccess-pathonpath(notpath-values); themakeHandleradapter andmakePathDispatchResolverdescriptions; and any "pathmatches lexical only" framing.README.md— add that apathdeny now also resists symlink-alias evasion (lines ~20 / ~71 describepathdenies for sensitive files).
Test Impact Analysis
- New tests the change enables:
AccessPath.forPathwith an explicitresolveBase(cd-folded base) andAccessPath.forLiteral(literal-only, empty boundary) — unit-testable directly.- The
pathtool gate denying a symlink whose canonical target matches apathdeny pattern. - The bash-path gate matching a
pathrule against a symlinked token's canonical form.
- Tests that become redundant: none removed; existing lexical-match assertions stay valid (lexical aliases are still in
matchValues()). - Tests that must stay as-is: the #393 unknown-base test in
test/access-intent/bash/program.test.ts(non-literalcd→ literal only) — it now pinsforLiteralbehavior and must keep asserting no canonical/absolute leakage.
Existing tests to migrate (interface/shape changes, same package, type-level breaks):
test/access-intent/access-path.test.ts—forExternalDirectory→forPath; addforLiteralcases.test/permission-resolver.test.ts,test/handlers/gates/external-directory-policy.test.ts—forExternalDirectory→forPath.test/access-intent/bash/program.test.ts—pathRuleCandidates()shape (policyValues→path: AccessPath); assert viapath.matchValues()/path.value().test/handlers/gates/bash-path*and anypath.tsgate tests — assert the emittedaccess-pathintent andpath.value()-derived approval base.
Invariants at risk
This change touches surfaces Phase 6 refactored. Documented invariants and their pinning tests:
- #418 external-directory matches lexical ∪ canonical — preserved by the
forPathrename (behavior-identical whenresolveBasedefaults tocwd). Pinned bytest/handlers/gates/external-directory-policy.test.tsandtest/access-intent/access-path.test.ts. - #393 unknown-base bash token keeps literal only — preserved by routing that case through
forLiteral. Pinned by the non-literal-cdcase intest/access-intent/bash/program.test.ts(extend it to assertmatchValues()carries no canonical/absolute). - #382 canonical is win32-lowercased —
forPathusescanonicalNormalizePathForComparison(unchanged). Pinned byaccess-path.test.ts. - #478 single
resolve(intent)entry point — unchanged; both producers still emit one intent throughresolve.
TDD Order
-
feat(pi-permission-system): add AccessPath.forPath and forLiteral factoriesTest surface:test/access-intent/access-path.test.ts. AddforPath(pathValue, { cwd, resolveBase })(generalized rename offorExternalDirectory) andforLiteral(literal); migrate the existingforExternalDirectorytests toforPathand update the three production call sites in the same commit (external-directory.ts,cwd-projection.ts×2) — removing an export breaks all importers at the type level, so fold them together. CoverforLiteral(matchValues[literal], empty boundary) andforPathwith an explicitresolveBase. Also updatetest/permission-resolver.test.tsandtest/handlers/gates/external-directory-policy.test.ts(rename) in this commit. -
feat(pi-permission-system)!: match the canonical form on the path tool gateTest surface: thepath.tsgate tests. Migratepath.tsto buildAccessPath.forPathand emitaccess-pathonpath; derive the approval pattern fromvalue(). Red: a tool reading a symlink whose canonical target matches apathdeny is now denied. Breaking —feat!:withBREAKING CHANGE:footer. -
feat(pi-permission-system)!: match the canonical form on the bash-path gateTest surface:test/access-intent/bash/program.test.ts+ bash-path gate tests. ChangeBashPathRuleCandidateto{ token, path: AccessPath }, rebuild candidates inprojectRuleCandidates(inline/removegetPolicyValuesForRuleCandidate, preserve theforLiteralunknown-base branch), and migratebash-path.tsto emitaccess-pathand derive the approval base frompath.value(). TheprojectRuleCandidatesreturn-type change and itsbash-path.tsconsumer + tests break together — one commit. Red: a bash token symlinked to apath-denied target is denied; the #393 unknown-base case still yields literal-only matches. Breaking —feat!:. -
refactor(pi-permission-system): drop the unproduced path-values emitted variantTest surface: type-level + fixtures. RemovePathValuesAccessIntentfrom the emittedAccessIntentunion (keep inResolvedAccessIntent); simplifytoResolvedIntent; updategate-fixtures.ts(makePathDispatchResolver,makeHandleradapter) to thetool | access-pathemitted surface.tscconfirms no remaining emitter. -
docs(pi-permission-system): document canonical path-surface matchingUpdatedocs/configuration.md,docs/architecture/architecture.md,.pi/skills/package-pi-permission-system/SKILL.md, andREADME.mdper Module-Level Changes. No release impact on its own (rides the breaking feat).
Risks and Mitigations
- Risk: the rename silently changes external-directory behavior.
Mitigation:
forPath(p, { cwd })is behavior-identical (proved above); the external-directory-policy and access-path tests pin it and run unchanged-in-intent. - Risk: the #393 unknown-base case regresses to over-matching (spurious canonical/absolute).
Mitigation: route it through
forLiteral; extend the existing program test to assertmatchValues()carries only the literal. - Risk: dedup or session-approval keys shift, invalidating in-flight approvals.
Mitigation: keys derive from
value()(lexical), which is unchanged; covered by existing approval/dedup tests. - Risk: an existing user config's
pathrule starts matching a previously-unmatched symlinked path on upgrade. This is the intended breaking behavior; mitigation is theBREAKING CHANGE:note and the docs update describing the new symlink-resistant matching.
Open Questions
- None blocking.
The residual #487 scope (config-pattern and prompt-input
AccessPathmigration) is unaffected; this plan narrows it by completing the bash-path migration and thepath-valuescollapse it listed. No new follow-up issue is filed (no new work is deferred — work is pulled forward).