22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 476 | pi-permission-system: introduce the AccessPath value object (Phase 6 Step 4) |
Introduce the AccessPath value object (Phase 6 Step 4)
Release Recommendation
Release: mid-batch — defer (batch "access-path-unification"); confirm at ship time
This is Step 4 of the Phase 6 access-intent roadmap, and the head of release batch "access-path-unification" (Steps 4, 5).
Step 4 alone leaves both the new AccessPath type and the old free helpers' boundary primitive in place — a transitional state where the two external-directory gates each still re-derive their own match values — so it ships with the batch tail, Step 5 (#477), which collapses both gates onto one shared AccessPath policy check.
Do not cut a release for Step 4 on its own; hold the release-please PR open until Step 5 lands.
Problem Statement
Today a single string carries a path's two distinct meanings: the lexical (as-typed, normalized but not symlink-resolved) form used for external_directory pattern matching, and the canonical (symlink-resolved) form used for the outside-CWD boundary decision.
This conflation produced a real bug — #418, where both external-directory gates matched config patterns against the symlink-resolved path instead of the typed path, defeating a configured /tmp/* allow.
The #418 fix added two free helpers (getExternalDirectoryPolicyValues, canonicalNormalizePathForComparison) and a docstring convention, but the misuse — passing a boundary value where a match value belongs — is still expressible.
The architecture doc names this pairing as "the embryo of the access-path value object": an AccessPath holding both forms behind distinct accessors would make the misuse a compile error rather than a convention.
Goals
- Add
src/access-intent/access-path.tswith anAccessPathvalue object exposingmatchValues(): string[](the lexical alias union ∪ canonical, the #418 match set),boundaryValue(): string(the canonical form), andvalue(): string(the lexical display form). - Route the single-tool external-directory gate (
describeExternalDirectoryGate) throughAccessPathfor its match values and infra-read canonical. - Change
BashProgram.externalPaths()to returnAccessPath[]instead of lexical strings, and route the bash external-directory gate (describeBashExternalDirectoryGate) throughAccessPath. - Fold
getExternalDirectoryPolicyValuesentirely intoAccessPath.matchValues()and remove it. - Behavior-preserving: the same paths are flagged, the same patterns match, the same boundary decisions hold. Not a breaking change — no user-facing config, output, or default changes.
Non-Goals
- Collapsing the two external-directory gates onto one shared policy check — that is Step 5 (#477).
Step 4 wires both gates onto
AccessPathindividually; each still re-derives its own match values. The boundary-logic single-sourcing happens in Step 5. - Narrowing
ScopedPermissionResolvertoresolve(intent)— Step 6 (#478). - Giving
AccessPatha boundary-decision method (isOutsideWorkingDirectory()). Per the operator's decision, Step 4 keepsAccessPathto accessors only;isPathOutsideWorkingDirectorystays a free function. Pulling the containment decision onto the value object would overlap Step 5's gate collapse. - Removing
canonicalNormalizePathForComparison. Per the operator's decision, it is retained as the shared boundary primitive: theAccessPathfactory composes it, andisPathOutsideWorkingDirectorystill calls it (on both the path and the cwd). OnlygetExternalDirectoryPolicyValuesis fully folded and removed. - Principal identity / cross-session path portability — deferred follow-ups named in the architecture's "Remaining design work", not in Phase 6 scope.
Background
Relevant modules and their current shapes:
src/path-utils.ts— ownsgetExternalDirectoryPolicyValues(pathValue, cwd): string[](lexical aliases ∪ canonical),canonicalNormalizePathForComparison(pathValue, cwd): string(symlink-resolved + win32-lowercased),getPathPolicyValues(pathValue, opts): string[](lexical alias union),normalizePathForComparison(pathValue, cwd): string(absolute lexical), andisPathOutsideWorkingDirectory(pathValue, cwd): boolean(boundary decision, canonicalizes both path and cwd).src/handlers/gates/external-directory.ts—describeExternalDirectoryGate: callsisPathOutsideWorkingDirectory(applicability),canonicalNormalizePathForComparison(infra-read containment), andgetExternalDirectoryPolicyValues(match values forresolver.resolvePathPolicy(..., "external_directory")).src/handlers/gates/bash-external-directory.ts—describeBashExternalDirectoryGate: readsbashProgram.externalPaths()(lexical strings) and callsgetExternalDirectoryPolicyValues(p, tcc.cwd)per path for the policy check.src/access-intent/bash/program.ts—BashProgram.externalPaths(): string[](parameter-free getter, born-ready since #475); the field isresolvedExternalPaths: readonly string[], populated atparse(command, cwd)fromprojectExternalPaths.src/access-intent/bash/cwd-projection.ts—projectExternalPaths(candidates, cwd): string[]; already computes bothlexical(absolute, cd-aware) andcanonicalper candidate, pushes thelexicalstring, dedups oncanonical.src/handlers/gates/bash-path-extractor.ts—extractExternalPathsFromBashCommand(command, cwd): Promise<string[]>, a thin test-facing facade overBashProgram.externalPaths().
Constraints from AGENTS.md / package skill that apply:
docs/architecture/architecture.mdtracks Phase 6 as a numbered step list plus a Mermaid graph; mark Step 4 ✅ (heading + node) as part of this change.- The win32 case-folding behavior (
PATH_SURFACES, #382) must be preserved — theAccessPathfactory must recompute the canonical viacanonicalNormalizePathForComparison(which lowercases on win32), not reuse a rawcanonicalizePathoutput that skips lowercasing. fallow dead-codegates CI: a newly-added export with no production consumer fails it, soAccessPathmust land with its first consumer in the same commit, andgetExternalDirectoryPolicyValuesmust be removed in the same commit its last consumer migrates.
Design Overview
AccessPath value object
A path-representation value object that holds the two forms behind type-distinct accessors.
The core of the #418 fix is that matchValues() returns string[] while boundaryValue() returns string — so the gate cannot accidentally pass the canonical boundary string to resolvePathPolicy(string[]); the misuse is a type error.
export class AccessPath {
private constructor(
private readonly lexical: string, // as-typed, normalized, NOT symlink-resolved (display/pattern)
private readonly matchAliases: readonly string[], // lexical alias union (getPathPolicyValues output)
private readonly canonical: string, // symlink-resolved + win32-folded; "" when unresolvable
) {}
/**
* Pattern-match values for the `external_directory` surface: the lexical
* alias union plus the canonical alias, so a config pattern on either the
* typed form (`/tmp/*`) or the resolved form (`/private/tmp/*`) matches (#418).
* Collapses to the lexical aliases when the canonical equals one of them.
*/
matchValues(): string[] {
return this.canonical
? [...new Set([...this.matchAliases, this.canonical])]
: [...this.matchAliases];
}
/** Canonical (symlink-resolved) form, for the outside-CWD boundary and infra-read containment. */
boundaryValue(): string {
return this.canonical;
}
/** Lexical (as-typed, normalized) form, for display, approval patterns, decision values, and logs. */
value(): string {
return this.lexical;
}
/** An external-directory tool/bash path resolved against `cwd`. */
static forExternalDirectory(pathValue: string, cwd: string): AccessPath {
return new AccessPath(
normalizePathForComparison(pathValue, cwd),
getPathPolicyValues(pathValue, { cwd }),
canonicalNormalizePathForComparison(pathValue, cwd),
);
}
}
matchValues() is exactly the body of today's getExternalDirectoryPolicyValues — getPathPolicyValues(pathValue, { cwd }) ∪ canonicalNormalizePathForComparison(pathValue, cwd) — so it is behavior-identical.
Single-tool gate call site (Tell-Don't-Ask check)
const accessPath = AccessPath.forExternalDirectory(externalDirectoryPath, tcc.cwd);
const canonicalExtPath = accessPath.boundaryValue(); // was canonicalNormalizePathForComparison(...)
if (isPiInfrastructureRead(tcc.toolName, canonicalExtPath, infraDirs, tcc.cwd)) { ... }
const preCheck = resolver.resolvePathPolicy(
accessPath.matchValues(), // was getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd)
tcc.agentName ?? undefined,
"external_directory",
);
The gate keeps the raw externalDirectoryPath string for messages, decision values, logs, and the applicability call isPathOutsideWorkingDirectory(externalDirectoryPath, tcc.cwd) — none of those move onto AccessPath in Step 4.
The approval pattern derivation deriveApprovalPattern(normalizePathForComparison(externalDirectoryPath, tcc.cwd)) is left as-is (it equals accessPath.value(), but keeping the explicit call avoids any behavior drift; an optional simplification, not required).
Bash gate + projection (extracted-module interaction check)
projectExternalPaths already computes lexical and canonical for its dedup/boundary loop; only the return value changes from the lexical string to an AccessPath.
The internal dedup seen set and the containment checks stay keyed on the raw canonical exactly as today — no behavior change there.
Each pushed path is wrapped via the factory, which recomputes the match aliases + canonical from the lexical string against cwd — identical to what the bash gate did before with getExternalDirectoryPolicyValues(p, tcc.cwd).
// cwd-projection.ts — projectExternalPaths now returns AccessPath[]
seen.add(canonical);
externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
forExternalDirectory(lexical, cwd) recomputes the canonical via canonicalNormalizePathForComparison (win32-folded), preserving #382 behavior — it does not reuse projection's raw canonicalizePath output (which skips win32 lowercasing).
This costs one extra realpathSync per external path inside the factory, but the bash gate previously paid the same call in getExternalDirectoryPolicyValues; net realpath calls are unchanged.
// bash-external-directory.ts
const externalPaths = bashProgram.externalPaths(); // AccessPath[]
for (const p of externalPaths) {
const check = resolver.resolvePathPolicy(
p.matchValues(), // was getExternalDirectoryPolicyValues(p, tcc.cwd)
tcc.agentName ?? undefined,
"external_directory",
);
if (check.state !== "allow") uncoveredEntries.push({ path: p, check });
}
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value()); // string[] for messages/denial/log
The downstream message, denial-context, decision, and log shapes stay string[] (uncoveredPaths), so external-directory-messages.ts and denial-messages.ts are untouched.
The all-allowed bypass log (currently externalPaths in its details) must map to externalPaths.map((p) => p.value()) since the variable is now AccessPath[].
Facade (bash-path-extractor.ts)
extractExternalPathsFromBashCommand is a test-only facade (its sole consumers are bash-external-directory.test.ts assertions).
Keep its Promise<string[]> contract by mapping .value():
return (await BashProgram.parse(command, cwd)).externalPaths().map((p) => p.value());
This preserves the ~90 projection-correctness assertions in bash-external-directory.test.ts unchanged (lift-and-shift; AGENTS.md guidance against rewriting a large test file wholesale).
Module-Level Changes
src/access-intent/access-path.ts— new.AccessPathclass (matchValues,boundaryValue,value, private ctor,forExternalDirectoryfactory). ImportsgetPathPolicyValues,normalizePathForComparison,canonicalNormalizePathForComparisonfrom#src/path-utils.src/path-utils.ts— removegetExternalDirectoryPolicyValues(folded intoAccessPath.matchValues()).canonicalNormalizePathForComparison,getPathPolicyValues,normalizePathForComparison,isPathOutsideWorkingDirectoryall retained.src/handlers/gates/external-directory.ts— build anAccessPath; replace thecanonicalNormalizePathForComparisoncall withboundaryValue()and thegetExternalDirectoryPolicyValuescall withmatchValues(). Drop those two imports; add theAccessPathimport. KeepisPathOutsideWorkingDirectory,normalizePathForComparison,getToolInputPath,isPiInfrastructureReadimports.src/handlers/gates/bash-external-directory.ts— consumeAccessPath[]fromexternalPaths(); usep.matchValues()/p.value(); map the all-allowed logexternalPathsto.value(). Drop thegetExternalDirectoryPolicyValuesimport; addimport type { AccessPath }(foruncoveredEntries'spathfield type).src/access-intent/bash/cwd-projection.ts—projectExternalPathsreturn typestring[]→AccessPath[]; pushAccessPath.forExternalDirectory(lexical, cwd)in both branches (the unknown-base relative branch and the resolved branch). Add theAccessPathimport.src/access-intent/bash/program.ts—resolvedExternalPaths: readonly string[]→readonly AccessPath[];externalPaths(): string[]→externalPaths(): AccessPath[]; update the constructor param type and the getter doc comment. Re-exportAccessPathis not needed (consumers import from#src/access-intent/access-path).src/handlers/gates/bash-path-extractor.ts— map.value()to keep thePromise<string[]>contract; update the doc comment.test/access-intent/access-path.test.ts— new. Unit tests forAccessPath(migrating thegetExternalDirectoryPolicyValuescases frompath-utils.test.tsasmatchValues()cases, plusboundaryValue()/value()/ factory cases incl. symlink resolution and the unresolvable-canonical collapse).test/path-utils.test.ts— remove thegetExternalDirectoryPolicyValuesdescribe block and its import. Keep thecanonicalNormalizePathForComparisondescribe block (helper retained).test/access-intent/bash/program.test.ts— adaptexternalPaths()assertions to map.value()(~25 sites:expect(program.externalPaths().map((p) => p.value())).toContain(...)/.toHaveLength(...)stays on the array length).test/handlers/gates/tool-call-gate-pipeline.test.ts— update theexternalPathsmock type fromvi.fn<() => []>tovi.fn<() => AccessPath[]>(still returns[]).docs/architecture/architecture.md— update theprogram.tstree-listing line (externalPaths(): string[]→externalPaths(): AccessPath[]); add anaccess-path.tsentry under theaccess-intent/tree; mark Step 4 ✅ on the step heading and theS4Mermaid node; update the "External-directory gate duplication" / metrics rows only if a value changes (the duplication row is Step 5's target — leave it).
No SKILL.md references the removed symbol or the externalPaths() return type (verified by grep), so no package-skill edit is needed.
Historical docs/plans/ and docs/retro/ files that mention these symbols are immutable history and are not edited.
Test Impact Analysis
- New tests enabled —
AccessPathis now unit-testable in isolation (test/access-intent/access-path.test.ts):matchValues()dedup/union,boundaryValue()canonical,value()lexical, and factory symlink resolution. Previously these behaviors were only reachable throughgetExternalDirectoryPolicyValues+canonicalNormalizePathForComparisonas separate free-function tests. - Redundant tests — the
getExternalDirectoryPolicyValuesdescribe block inpath-utils.test.ts(the typed+symlink alias union, the dedup-when-equal case, the in-cwd relative-alias case) becomes redundant and migrates toaccess-path.test.tsasmatchValues()cases. - Tests that must stay as-is — the
canonicalNormalizePathForComparisondescribe block inpath-utils.test.ts(helper retained); the external-directory integration tests (external-directory-integration.test.ts,external-directory-session-dedup.test.ts,bash-external-directory.test.ts) that genuinely exercise the #418 symlink-alias matching end-to-end through the gates — these pin the behavior-preservation and must stay green with only the facade's string view unchanged.
Invariants at risk
This step retypes a surface that Step 3 (#475) just settled.
- #475 outcome —
BashProgramborn-ready, parameter-free getters,cwdnarrowed tostring. Step 4 changes only theexternalPaths()return element type (string→AccessPath); the parameter-free, born-ready, eager-resolution shape is preserved. Pinned bytest/access-intent/bash/program.test.ts(adapted to.value()). - #418 outcome — a config pattern on the typed path matches even when the path is a symlink.
Pinned by the migrated
matchValues()tests inaccess-path.test.tsand the gate-level symlink-alias tests inbash-external-directory.test.ts/external-directory-integration.test.ts. These must stay green; theAccessPath.matchValues()body is the literal old helper body, so the alias union is unchanged. - #382 outcome — win32 case-folding on path surfaces.
Preserved by having the factory recompute the canonical through
canonicalNormalizePathForComparison(win32-lowercasing) rather than a rawcanonicalizePath.
TDD Order
- Introduce
AccessPathand route the single-tool external-directory gate through it. Red:test/access-intent/access-path.test.ts—matchValues()(union, dedup-when-equal, in-cwd relative aliases),boundaryValue()(canonical, win32-fold, unresolvable →""),value()(lexical). Green: addsrc/access-intent/access-path.ts; rewireexternal-directory.tsto build anAccessPathand callboundaryValue()+matchValues().AccessPathlands with a production consumer (no dead-code flag);getExternalDirectoryPolicyValuesretains its bash consumer so it is not yet dead. Existing external-directory integration tests stay green (behavior-preserving). Commit:feat(pi-permission-system): introduce AccessPath value object - Return
AccessPath[]fromBashProgram.externalPaths, route the bash gate, and removegetExternalDirectoryPolicyValues. One atomic commit (the type-checker couples theexternalPaths()return-type change to its consumers, andfallow dead-codecouplesgetExternalDirectoryPolicyValues's removal to its last consumer's migration). Red/adapt:program.test.tsassertions map.value(); migrate thegetExternalDirectoryPolicyValuescases out ofpath-utils.test.tsintoaccess-path.test.ts; update thetool-call-gate-pipeline.test.tsmock type. Green: changeprojectExternalPathsreturn type + pushAccessPath; retypeprogram.tsfield/getter; rewirebash-external-directory.ts(usematchValues()/value(), map the bypass log); map.value()inbash-path-extractor.ts; removegetExternalDirectoryPolicyValuesfrompath-utils.ts. Runpnpm run test+pnpm fallow dead-codeto confirm no consumer was missed and nothing is newly dead. Commit:refactor(pi-permission-system): return AccessPath[] from externalPaths and remove getExternalDirectoryPolicyValues - Update the architecture doc.
Update the
program.tstree line (externalPaths(): AccessPath[]), add theaccess-path.tstree entry, and mark Step 4 ✅ (heading +S4node). No test cycle. Commit:docs(pi-permission-system): record AccessPath value object in Phase 6 architecture
Risks and Mitigations
- Win32 canonical drift — reusing projection's raw
canonicalizePathoutput (no lowercasing) in the match set would regress #382. Mitigation: the factory recomputes viacanonicalNormalizePathForComparison; explicit note in Design Overview and an invariant entry. - Silent dead-code / false-green from a missed consumer — removing an export and retyping a getter can leave a stubbed test path passing
allow. Mitigation: Step 2 runspnpm run test+pnpm fallow dead-codebefore commit; the package-skill warns thatmakeSurfaceCheckstubs must routeresolvePathPolicy— the integration tests (not just the edited file) gate this. - Large test-assertion churn in
program.test.ts— ~25.value()adaptations. Mitigation: mechanical mapping, single step; no logic change. - Transitional duplication between the two gates — Step 4 leaves each gate deriving its own
AccessPath. Mitigation: intentional and tracked — Step 5 (#477) collapses them; release is deferred to the batch tail so the transitional state never ships alone.
Open Questions
- None blocking.
The two design forks (accessors-only vs. boundary-method; retain vs. remove
canonicalNormalizePathForComparison) were resolved with the operator: accessors-only and retain the primitive. WhetherAccessPatheventually grows a boundary-decision method is a Step 5 (#477) consideration, not Step 4.