16 KiB
Phase 7: AccessPath as the universal internal path representation
Phase 7 finished the direction opened by #487: make AccessPath the one internal representation for every concrete path the system handles.
Phase 6 introduced AccessPath for the external_directory surface; follow-on #486 brought the path surface and the bash-path tokens to lexical ∪ canonical parity and collapsed the gate-emitted path-values variant.
Two ad-hoc path-derivation paths once normalized lexically only — the per-tool path-bearing gate (read/write/edit/grep/find/ls) and the service/RPC policy-query path — so a per-tool rule (read: deny *.env) was symlink-evadable while the cross-cutting path rule was not.
Steps 1 (#502) and 2 (#503) routed both onto AccessPath (closing the asymmetry, a breaking change); Phase 7 also retired the now-dead lexical-only normalization, consolidated the path-utils.ts derivation hub behind the value object, and formalized the resolver-internal path-values boundary.
This was a direction-driven phase: #487 set the framing, and the discovery confirmed the residual surface rather than proposing an unrelated health sweep.
Findings
Health score 76 (B); no dead code; duplication 6.6% overall (3.6% in tests); maintainability 91.2.
The single relevant structural signal was path-utils.ts — an accelerating churn hotspot (266 churn over 6 months, 13 fan-in, ▲), the ad-hoc path-derivation grab-bag the #487 vision exists to consolidate.
| Metric | Before | After Phase 7 |
|---|---|---|
path-utils.ts fan-in |
13 (one grab-bag) | ✅ distributed across six cohesive modules (#505) |
| Lexical-only path normalizers | 2 (per-tool gate, service/RPC) | ✅ 0 (single AccessPath derivation) |
| Symlink-resistant path surfaces | path, external_directory, bash |
✅ all path surfaces incl. per-tool and RPC |
| Emitted/internal path-value forms | access-path emitted, path-values internal |
✅ path-values formalized as the string seam (decisions/0002) |
The residual ad-hoc path handling (the "re-derive their representations ad hoc" #487 names):
- Per-tool path-bearing gate:
ToolCallGatePipelineemittedkind: "tool"→normalizeInput→normalizePathSurfaceValues→getPathPolicyValues(lexical only) — closed by Steps 1–3 (#502, #504): Step 1 migrated the gate to emitaccess-path; Step 3 removednormalizePathSurfaceValuesand the path branches fromnormalizeInput. - Service/RPC queries:
permissions-service.ts/permission-event-rpc.ts— closed by Step 2 (#503): both build anAccessPathviabuildAccessIntentForSurfaceand route anaccess-pathintent through the resolver (was a lexicaltoolintent forpath/external_directory). path-utils.ts: the loosegetPathPolicyValues/normalizePathForComparison/normalizePathPolicyLiteralderivations thatAccessPathshould own — ✅ closed by Step 4 (#505): relocated intoaccess-intent/path-normalization.tsand the grab-bag dissolved into focused modules.- ✅
path-values: formalized as the manager's deliberate string boundary by Step 5 (#506;docs/decisions/0002-path-values-string-boundary.md) — the manager stays string-based and never importsAccessPath, now guarded by ano-restricted-importslint rule onpermission-manager.ts.
Steps
-
✅ Migrate the per-tool path-bearing tool gate onto
AccessPath(canonical parity). (#502) Target:src/handlers/gates/tool-call-gate-pipeline.ts(buildAccessPath.forPathand emitkind: "access-path"withsurface: toolNamefor path-bearing tools, keeping non-path tools on thetoolintent),src/handlers/gates/tool.ts(derive the session-approval value fromaccessPath.value()). The resolver already unwrapsaccess-path→path-valuesand the manager's path-value branch already routesPATH_BEARING_TOOLSthroughevaluateAnyValue, so the only behavior change is the canonical alias joining the match set — mechanically parallel to #486. Smell: Category C (coupling / match asymmetry). Outcome:read/write/edit/grep/find/lsper-tool rules match lexical ∪ canonical (symlink-resistant); breaking. Release: batch "symlink-resistant-path-matching" -
✅ Migrate the service/RPC path queries onto
AccessPath(canonical parity). (#503) Target:src/permissions-service.ts,src/permission-event-rpc.ts,src/input-normalizer.ts(buildAccessIntentForSurface). Forpath/external_directory/ path-bearing surface queries, build anAccessPathand route anaccess-pathintent through the resolver instead of a lexicaltoolintent to the manager; non-path surfaces keep the existing path. Routing through the resolver (not a secondpath-valuesproducer) keeps it the solematchValues()unwrap site, the premise Step 5 (#506) decides against. Also fixed a latent gap: thepathand path-bearing service/RPC queries dropped their value (collapsing to["*"]) and now evaluate the supplied path. Smell: Category C (coupling / match asymmetry). Outcome: external policy queries match the same lexical ∪ canonical set the gates do; breaking for external consumers. Release: batch "symlink-resistant-path-matching" -
✅ Retire
input-normalizer's path normalization. (#504) RemovednormalizePathSurfaceValues, the special-surface (path/external_directory) branch, and thePATH_BEARING_TOOLSbranch fromnormalizeInput; dropped theplatform/cwdparameters; removed thecurrentCwdfield fromPermissionManager. After Steps 1 and 2, these branches had no callers; the missing-path case falls through to the generic["*"]branch. Smell: Category A (dead / redundant code). Outcome:normalizeInputhandles only bash / skill / mcp / extension surfaces; a singleAccessPathpath-derivation entry remains. Release: batch "symlink-resistant-path-matching" -
✅ Consolidate path derivation behind
AccessPath: dissolve thepath-utils.tsgrab-bag. (#505) Relocated the lexical/canonical/policy-value derivation (normalizePathForComparison,canonicalNormalizePathForComparison,getPathPolicyValues,normalizePathPolicyLiteral, and the two private absolute/relative helpers) intosrc/access-intent/path-normalization.tsasAccessPath's backing; kept containment (isPathWithinDirectory,isPathOutsideWorkingDirectory) together insrc/path-containment.ts, and split infra-read (pi-infrastructure-read.ts), tool-input extraction (tool-input-path.ts), safe-system paths (safe-system-paths.ts), and the surface/tool sets (path-surfaces.ts) into focused modules. A "tidy first" prep refactor madeisPathOutsideWorkingDirectorypure geometry over prepared operands (canonicalization moved up toPathNormalizer), which dissolved the apparent representation↔containment cycle so the literal grouping held. Smell: Category B / E (god module, accelerating churn hotspot). Outcome:path-utils.tsdissolved into cohesive modules; path derivation owned by the access-intent domain; non-breaking. Release: independent -
✅ Decide and formalize the
path-valuesboundary. (#506) Target:src/access-intent/access-intent.ts,src/permission-resolver.ts,src/permission-manager.ts. With the resolver the solepath-valuesproducer after Steps 1 and 2, decide between formalizingpath-valuesas the manager's intentional string seam (document why the manager stays string-based) and moving thematchValues()unwrap into the manager (the manager importsAccessPath, dropping the string-boundary invariant). This is the #487 "collapse thepath-valuesvariant" item, resolved as an explicit decision rather than a pre-committed mechanical change. Smell: Category C (clarify boundary). Decided: formalize — keptpath-valuesas the string seam, recorded indocs/decisions/0002-path-values-string-boundary.md, and guarded the invariant with ano-restricted-importslint rule onpermission-manager.ts; non-breaking. Release: independent
Step dependency diagram
flowchart TD
S1["✅ Step 1 (#502)<br/>Per-tool gate to AccessPath<br/>(breaking)"]
S2["✅ Step 2 (#503)<br/>Service/RPC to AccessPath<br/>(breaking)"]
S3["✅ Step 3 (#504)<br/>Retire input-normalizer path normalization"]
S4["✅ Step 4 (#505)<br/>Dissolve path-utils grab-bag"]
S5["✅ Step 5 (#506)<br/>Decide path-values boundary"]
S1 --> S3
S2 --> S3
S3 --> S4
S1 --> S5
S2 --> S5
Parallel tracks
- Track A — access-side canonical parity: Steps 1 and 2 proceed in parallel (different consumers), both feed Step 3 (dead-code removal), and both unblock Step 5 (the boundary decision).
- Track B — structural consolidation: Step 4 follows Step 3 (fewer loose
path-utils.tsconsumers makes the relocation mechanical) and is otherwise independent.
Release batches
- Batch "symlink-resistant-path-matching": Steps 1, 2, 3 (ship together; tail = Step 3). Steps 1 and 2 are breaking parity changes and Step 3 is their cleanup — they form one coherent "paths now match symlink-resistantly on every surface" major-bump release.
- Independently releasable: Step 4 (a refactor that auto-batches into the next release), Step 5 (a decision / docs change).
Non-goals
- Config patterns onto
AccessPath. Patterns are matched as pure regex (*compiles to.*with the dotall flag and crosses path segments —wildcard-matcher.ts), so a glob is a matching mode, not a value with a canonical form. The symlink protection #487 wants is delivered on the access side: an accessed path'smatchValues()carries both its lexical "source" and canonical "target", and a rule fires on either — so a rule on the symlink path or on the real file both match. The only uncovered case is a glob pattern whose directory prefix is a symlink (e.g.~/linkdir/*accessed via the real target): not closable on the pattern side, because*crossing segments leaves no reliable resolvable-prefix decomposition. Documented guidance: key glob rules on the real location, not a symlink-dir alias. - Canonicalizing concrete symlink patterns at rule-load. Feasible only for fully-concrete (non-glob) patterns and a narrow case; evaluated and dropped (the high-value protective patterns are globs, which this cannot help).
- Principal identity and cross-session path portability. Still deferred (the broader access-intent design work), out of Phase 7 scope.
Related: PathNormalizer platform seam (#510)
A precursor refactor (not one of the five steps above) threaded a single injected PathNormalizer collaborator through the bash path pipeline, completing the half-built platform seam behind the recurring Windows-path bugs (#382, #345, #418, #508).
The host platform is read once at the composition root (index.ts) and injected: into PermissionManager (rule-matching case-fold), into PermissionSession (which builds the PathNormalizer from platform + the session cwd and exposes it via getPathNormalizer()), and into the subagent-context detection.
No interior src/ module reads process.platform — an ESLint no-restricted-syntax guard scoped to pi-permission-system/src (exempting index.ts) enforces this, so every path-containment / path-normalization / canonicalize / rule / subagent-context leaf takes an injected platform rather than a = process.platform default.
PathNormalizer is a facade over the platform-parameterized path-containment / path-normalization / AccessPath primitives: Phase 7 Step 4 (#505) dissolved path-utils.ts into those cohesive modules (the seam was untouched — the facade kept the same leaf calls under new module names).
The change is behavior-preserving on POSIX (every converted op already used the host node:path); the win32 flavor is newly exercised by injected-platform unit tests, and #508 then lands the drive-letter routing fix on the seam.
Residual getPlatform() threading (follow-up #511)
The seam left five call sites threading platform directly rather than through PathNormalizer, because they call raw path-leaf functions that are not AccessPath operations.
PermissionSession.getPlatform() (and the ToolCallGateInputs.getPlatform() it backed) existed only to feed them; it has been retired now that every consumer is folded, while the leaf platform parameters in the relocated path modules (path-containment.ts, path-normalization.ts, pi-infrastructure-read.ts) persist.
How each relates to the Phase 7 steps above:
- Per-tool gate suggestion value (
handlers/gates/tool.tsderiveSuggestionValue→normalizePathForComparison) — ✅ retired by Step 1 (#502):deriveSuggestionValuenow derives the session-approval value fromaccessPath.value(), dropping theplatformthread intodescribeToolGate. input-normalizerpath-policy values (normalizePathSurfaceValues→getPathPolicyValues) — ✅ retired by Steps 2–3 (#503, #504): Step 2 migrated the service/RPC path queries ontoAccessPath; Step 3 removed the path-bearing/special-surface branches fromnormalizeInputentirely (#504).- Infra-read containment (
handlers/gates/external-directory.ts) — ✅ routed throughPathNormalizer.isInfrastructureRead(#511): the gate already holds the normalizer, which now answers the containment question over the already-builtAccessPath. Step 4 (#505) still keepsisPiInfrastructureRead(pi-infrastructure-read.ts) andisPathWithinDirectory/isPathOutsideWorkingDirectory(path-containment.ts) as platform-taking leaf predicates that the normalizer delegates to. - Skill-prompt sanitization (
skill-prompt-sanitizer.tscreateResolvedSkillEntry/findSkillPathMatch; reached frombefore-agent-start.tsandhandlers/gates/skill-read.ts) — ✅ routed throughPathNormalizer.comparableValue/isWithinDirectory(#511). Skill entries still cachenormalizedLocation/normalizedBaseDiras lexical strings (matching stays lexical, no new filesystem access), but they are now computed by the normalizer rather than by directnormalizePathForComparisoncalls.
✅ getPlatform() has been removed: with both #511 and Step 1 (#502) landed, ToolCallGatePipeline.evaluate no longer reads it, so PermissionSession.getPlatform() and ToolCallGateInputs.getPlatform() were dropped (#513 resolved).
The leaf platform parameters in path-containment.ts / pi-infrastructure-read.ts persist (the containment / infra-read predicates still take it).