27 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 510 | Thread an injected platform/path-semantics seam through the bash path pipeline |
Thread a PathNormalizer collaborator through the bash path pipeline
Release Recommendation
Release: ship independently
This issue is not part of a named roadmap batch.
It is a behavior-preserving refactor:, so it does not cut a release on its own — it lands on main and auto-batches into the next feat:/fix: release.
In practice that release is #508's fix: (the Windows drive-letter gate), which is sequenced to land immediately after this refactor and depends on the seam it establishes.
The rationale must not claim this issue itself cuts a release.
Problem Statement
The bash path pipeline reads process.platform ambiently — directly, or through the host-bound node:path import — in roughly six interior modules, instead of injecting the platform once at the edge and threading it through.
The package already discovered the injectable-platform pattern (isPathWithinDirectory, rule.ts shouldFoldCase) but applied it only to a few leaf predicates, never end-to-end.
This half-built seam is the root cause behind the recurring Windows-path bugs (#382, #345, #418, #508): each fix either hand-rolls a platform check that drifts from node:path (the #508 startsWith("/") drift) or reaches for a fragile module-level vi.mock("node:path") to test Windows behavior on a POSIX CI.
The correction is to stop threading platform knobs into the pipeline and re-deriving path forms during evaluation. Instead, inject a single collaborator constructed at the edge with the platform (and the working directory) baked in, hand it raw path tokens, and let it return the normalized values the gates expect. Path interpretation — "by which platform's rules, and against which working directory, do we read this path?" — becomes one collaborator's job, prepared before evaluation rather than scattered through it.
Goals
- Introduce a
PathNormalizercollaborator constructed once at the edge, carrying the platform and the session working directory, that produces the pipeline's prepared path values (AccessPaths) and answers the platform-dependent routing questions (absoluteness,cd-base resolution, working-directory containment). - Thread
PathNormalizerthrough the bash path pipeline:BashProgram.parse→cwd-projection.ts→ the per-tool and external-directory path gates, replacing the host-boundnode:pathimport, the inlineprocess.platformternaries, and the ad-hoccwdthreading. - Complete the half-built leaf seam:
normalizePathForComparison,canonicalNormalizePathForComparison,canonicalizePath, andAccessPath.forPath/forLiteralaccept the platform flavor instead of readingprocess.platforminline. - Eliminate every interior
process.platformread (path-utils.ts,subagent-context.ts,rule.tsdefaults) so the only reader is the composition-root edge, and add a lint guard that makes that enforceable. - Make Windows path behavior testable on a POSIX CI by injecting a
win32PathNormalizer— novi.mock("node:path").
This is a behavior-preserving refactor (refactor:).
Every interior op converted to a PathNormalizer call already used the host-bound node:path (so it already behaved as win32 on Windows and posix on POSIX); the one POSIX-hard-coded drift — isRelativeCandidate's startsWith("/") — is intentionally left as-is and deferred to #508 (see Non-Goals).
On the POSIX CI there is no observable change; the win32 seam is newly exercised only by injected-platform unit tests.
Non-Goals
- The
isRelativeCandidate→ injectedisAbsoluteconversion. Convertingcwd-projection.ts'sisRelativeCandidatefrom the hand-rolled!candidate.startsWith("/")to!normalizer.isAbsolute(candidate)changes Windows routing for drive-letter tokens — it is the semantic Windows correction, not a structural threading change. It is deferred to #508 (whose plan already folds it into its step 1), so this issue stays strictly behavior-preserving.isRelativeCandidateremains pure host-independent string matching (noprocess.platform, nonode:path), so it does not block the lint guard. - Recognizing Windows drive-letter shapes in the classifiers.
That is the #508 fix (
token-classification.ts); this issue only builds the seam #508 lands on. - Dissolving
path-utils.tsinto the access-intent domain. That relocation is Phase 7 Step 4 (#505).PathNormalizeris a facade over the (now platform-parameterized)path-utilsandAccessPathprimitives; it does not move them. #505 can later relocate those internals behind the facade without changing it. - Config-pattern path handling and prompt-input paths. Out of scope (#487 residual work); the gates' pattern matching stays as-is.
- Extending the
process.platformlint guard package-wide or to other packages. The guard is scoped topi-permission-system/src; no concrete need is named for widening it.
Background
Relevant modules and the platform reads they carry today:
src/path-utils.ts—normalizePathForComparisonandcanonicalNormalizePathForComparisonhard-codeprocess.platform === "win32"inline;isPathWithinDirectoryandisPiInfrastructureReadalready take an injectableplatform: NodeJS.Platform = process.platform.src/canonicalize-path.ts—canonicalizePathsplits and rejoins on/only (POSIX-only); takes no flavor.src/access-intent/access-path.ts—AccessPath.forPath/forLiteralare static factories delegating to the threepath-utilsnormalizers above; no platform option.src/access-intent/bash/cwd-projection.ts— importsisAbsolute/join/resolvefrom the host-boundnode:path, hand-rollsisRelativeCandidatewithstartsWith("/"), and callsisPathWithinDirectory(canonical, normalizedCwd)without a platform argument (so even the one good seam falls back to the host).src/access-intent/bash/program.ts—BashProgram.parse(command, cwd)threads a barecwdinto the projection.src/subagent-context.ts—normalizeFilesystemPathandisPathWithinDirectoryForSubagentre-hand-rollprocess.platform === "win32"for case-folding and the separator.src/rule.ts—evaluateandevaluateMostRestrictive/evaluateFirst/pathMatchOptionsdecide case-insensitive matching via an injectableplatform: NodeJS.Platform = process.platform(already injectable, but every caller relies on the default rather than supplying it).
Two distinct edges (this shapes the wiring):
process.platformis process-global and known when the extension factory runs (index.ts). It can be read once there and injected intoPermissionManager(forrule.tsmatching) andPermissionSession(to build the normalizer).cwdis session-scoped. It is not available in the factory body; it arrives atsession_startasctx.cwd(handlers/lifecycle.ts→session.resetForNewSession(ctx);session.getRuntimeContext()?.cwd). SoPathNormalizeris constructed when the session resets, not in the factory body.
Constraints from AGENTS.md / package skill:
code-design: do not readprocess.platforminside library/utility functions — accept it as a parameter (or, here, bake it into the collaborator at the edge).- The bash gates share a single
BashProgram.parseperevaluate(#308); the seam must not reintroduce a re-parse. - The pipeline already pulls session-scoped values (
getInfrastructureReadDirs,getToolPreviewLimits) fromPermissionSessionvia theToolCallGateInputsinterface; the normalizer follows that established pattern (getPathNormalizer()). docs/architecture/architecture.mdinline-copies therule.tstypes; touchingrule.ts's signature means checking that listing.
Design Overview
The PathNormalizer collaborator
A single value-bound collaborator, constructed at the edge with the two ambient inputs baked in, and handed raw tokens thereafter.
export class PathNormalizer {
constructor(
private readonly platform: NodeJS.Platform,
private readonly cwd: string,
) {}
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath;
/** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
forLiteral(literal: string): AccessPath;
/** Platform-aware absoluteness (`win32` vs `posix` rules). */
isAbsolute(pathValue: string): boolean;
/** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
resolveBase(offset: string): string;
/** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */
joinBase(offset: string, target: string): string;
/** Containment of `pathValue` within `directory` (platform-aware). */
isWithinDirectory(pathValue: string, directory: string): boolean;
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
isOutsideWorkingDirectory(pathValue: string): boolean;
}
The methods are intention-revealing domain operations ("is this path absolute under our platform", "resolve a cd offset against our cwd"), not a generic re-export of node:path.
Internally PathNormalizer selects path.win32/path.posix and the case-fold once, and delegates to the platform-parameterized path-utils/canonicalize-path/AccessPath.forPath primitives.
No consumer sees platform, selects a flavor, or threads cwd.
Consumer call sites (Tell-Don't-Ask check)
Projection (cwd-projection.ts) — hands the normalizer a token, gets a prepared AccessPath; no cwd, no node:path:
// buildRuleCandidatePath
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
return normalizer.forLiteral(normalizePathPolicyLiteral(candidate));
}
const resolveBase =
base.kind === "known" ? normalizer.resolveBase(base.offset) : undefined;
return normalizer.forPath(candidate, { resolveBase });
foldCd asks the normalizer the platform questions instead of importing them:
if (normalizer.isAbsolute(target)) return { kind: "known", offset: target };
if (base.kind === "unknown") return UNKNOWN_BASE;
return { kind: "known", offset: normalizer.joinBase(base.offset, target) };
Per-tool path gate (path.ts) — the ambient tcc.cwd is gone; the session's normalizer already carries it:
const accessPath = normalizer.forPath(filePath);
The normalizer reaches the gates the same way the infra-dir list does — the pipeline pulls it from the session per evaluate:
// tool-call-gate-pipeline.evaluate
const normalizer = this.inputs.getPathNormalizer();
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(command, normalizer)
: null;
Edge wiring
// index.ts (factory body) — the single process.platform read
const hostPlatform = process.platform; // eslint guard exemption: composition root
const permissionManager = new PermissionManager({ agentDir, platform: hostPlatform });
session = new PermissionSession(/* …, */ hostPlatform);
// PermissionSession.resetForNewSession(ctx) — cwd now known
this.pathNormalizer = new PathNormalizer(this.platform, ctx.cwd);
PermissionSession exposes getPathNormalizer(): PathNormalizer, added to the ToolCallGateInputs interface alongside the existing query methods.
Lint guard
A flat-config block scoped to packages/pi-permission-system/src/**/*.ts forbids process.platform, exempting only the composition root (index.ts):
{
files: ["packages/pi-permission-system/src/**/*.ts"],
ignores: ["packages/pi-permission-system/src/index.ts"],
rules: {
"no-restricted-syntax": ["error", {
selector: 'MemberExpression[object.name="process"][property.name="platform"]',
message: "Read process.platform only at the composition root; inject the platform (PathNormalizer / rule platform) into interior modules.",
}],
},
}
process.env (used legitimately by subagent-context.ts for subagent env hints) is untouched — the guard targets process.platform only.
Edge cases preserved
- Behavior parity per platform.
Every converted op already used host
node:path/process.platform; with the default platform = host, each produces the identical result.canonicalizePathgainswin32-aware splitting — a no-op on POSIX (splits on/as before) and a latent correctness gain on Windows that becomes observable only once #508 feeds drive tokens through it; validated here by injected-win32unit tests. - #393 literal-only guard.
A relative candidate under an unknown
cdbase still routes toforLiteral—isRelativeCandidateis unchanged. - #418 lexical-vs-canonical split.
AccessPath's accessors and the projection's "boundary uses canonical, returned value is lexical" logic are unchanged; only their construction is platform-parameterized. - #308 single parse.
BashProgram.parsestill parses once; it gains the normalizer in place ofcwd.
Module-Level Changes
src/path-utils.ts— addplatform: NodeJS.PlatformtonormalizePathForComparisonandcanonicalNormalizePathForComparison(replacing the inlineprocess.platform === "win32"), threading it to the private absolute/relative helpers and the existingisPathWithinDirectorycall; remove the= process.platforminline reads (the injectable defaults onisPathWithinDirectory/isPiInfrastructureReadare removed in the lint-guard step once all callers supply it).src/canonicalize-path.ts—canonicalizePathaccepts the platform flavor (orPlatformPath) and splits/rejoins on the platform separator (win32-aware), defaulting to host.src/access-intent/access-path.ts—forPath/forLiteralaccept aplatformoption, threaded to the three normalizers.src/path-normalizer.ts— newPathNormalizerclass (platform + cwd baked) wrappingAccessPath.forPath/forLiteraland exposingisAbsolute/resolveBase/joinBase/isWithinDirectory/isOutsideWorkingDirectory.src/access-intent/bash/cwd-projection.ts— drop theimport { isAbsolute, join, resolve } from "node:path";projectExternalPaths/projectRuleCandidates/buildRuleCandidatePath/foldCdtake aPathNormalizerin place of thecwdparameter and call its methods;isRelativeCandidatestaysstartsWith(deferred to #508).src/access-intent/bash/program.ts—BashProgram.parse(command, normalizer)replacescwd; threadnormalizerinto the projection calls.src/handlers/gates/bash-path-extractor.ts—BashProgram.parse(command, normalizer).src/handlers/gates/tool-call-gate-pipeline.ts— pullgetPathNormalizer()frominputs; pass it toBashProgram.parseand the path-gate producers; addgetPathNormalizerto theToolCallGateInputsinterface.src/handlers/gates/path.ts,src/handlers/gates/external-directory.ts,src/handlers/gates/bash-external-directory.ts,src/handlers/gates/bash-path.ts— replaceAccessPath.forPath({ cwd: tcc.cwd })with the injectednormalizer.forPath(...).src/permission-session.ts— constructor acceptsplatform;resetForNewSessionbuilds thePathNormalizerfrom{ platform, ctx.cwd }; addgetPathNormalizer(); declare it on theToolCallGateInputsit implements.src/permission-manager.ts— constructor acceptsplatform; supply it to theevaluate/evaluateFirst/evaluateAnyValuecalls.src/rule.ts— remove the= process.platformdefaults onevaluateandevaluateMostRestrictive/evaluateFirst(threadplatformfrom the manager);pathMatchOptions/ruleMatchesalready take it.src/subagent-context.ts—normalizeFilesystemPathandisSubagentExecutionContextacceptplatform(injected from the composition root via the lifecycle/registry caller); remove the hand-rolledprocess.platform === "win32"branches.src/index.ts— readprocess.platformonce; inject intoPermissionManager,PermissionSession, and thesubagent-contextcaller path.eslint.config.js(repo root) — add theprocess.platformno-restricted-syntaxblock scoped topi-permission-system/src, exemptingindex.ts.packages/pi-permission-system/docs/architecture/architecture.md— record thePathNormalizerseam and its relationship to Phase 7 #505; update therule.tstype/signature listing if theevaluatesignature note changes; add a roadmap entry referencing #510 (the issue notes this is "probably a roadmap step")..pi/skills/package-pi-permission-system/SKILL.md— update the path/platform handling notes (the "check how pi-coding-agent solves it" debugging note and anyprocess.platformguidance) to point atPathNormalizeras the single home; note the lint guard.
A grep of src/, test/, architecture.md, and the package SKILL confirms process.platform lives only in the modules listed above; AccessPath.forPath/forLiteral callers are cwd-projection.ts and the four gates listed (all migrated); BashProgram.parse callers are tool-call-gate-pipeline.ts and bash-path-extractor.ts (both migrated).
Test Impact Analysis
- New tests enabled.
Injected-
win32unit tests across the seam withoutvi.mock("node:path"):path-utilsnormalizers andcanonicalizePathdriven withplatform: "win32";AccessPath.forPathwith thewin32option; aPathNormalizerunit suite (both flavors) coveringforPath/forLiteral/isAbsolute/resolveBase/joinBase/containment; and an end-to-end projection/external-directory assertion driving awin32PathNormalizerthroughBashProgram.parse(the seam #508 then exercises with drive tokens). - Redundant tests.
Any existing Windows-path test relying on a
process.platformstub orvi.mock("node:path")for these modules can be simplified to inject awin32PathNormalizer/platform (none currently exist for the bash pipeline —skill-prompt-sanitizer.test.ts'snode:pathmock is a different module, out of scope). - Tests that must stay as-is.
The existing POSIX projection /
bash-external-directory/program/access-path/path-utils/rulesuites are the regression guard that POSIX behavior is unchanged; they stay green untouched (modulo the mechanical signature migration —BashProgram.parse(command, cwd)→(command, normalizer),AccessPath.forPath({cwd})call shape).
Invariants at risk
This change touches surfaces earlier phases refactored; their documented outcomes must not regress:
- #418 (lexical-vs-canonical conflation is a compile error) — pinned by
test/access-intent/access-path.test.tsand the projection's lexical-return/canonical-boundary assertions inbash-external-directory.test.ts. Preserved: only construction is platform-parameterized. - #393 (relative candidate under unknown
cdbase stays literal-only) — pinned by the unknown-base projection tests inprogram.test.ts/bash-external-directory.test.ts. Preserved:isRelativeCandidateunchanged. - #308 (single
BashProgram.parseperevaluate) — pinned byprogram.test.tsand the pipeline tests. Preserved: parse count unchanged. - #382 (
win32boundary values lowercased) — pinned by thewin32path-utils/AccessPathtests; thePathNormalizerwin32suite extends this coverage. - #478 (single resolver/manager resolution entry point) — the manager's new
platformfield does not add a resolution method; pinned by the manager/resolver suites.
TDD Order
-
refactor:Thread the platform flavor through the leaf normalizers (preparatory, additive). Red:path-utils.test.tscases assertingnormalizePathForComparison/canonicalNormalizePathForComparisonwithplatform: "win32"lowercase and usewin32resolution;canonicalize-path.test.tscases assertingwin32-separator splitting. Green: add theplatformparameter (defaulting to host) tonormalizePathForComparison,canonicalNormalizePathForComparison, andcanonicalizePath, threading to the private helpers. Commit:refactor(pi-permission-system): accept platform flavor in path normalizers (#510). -
refactor:Add the platform option toAccessPath.forPath/forLiteral. Red:access-path.test.tscases building awin32AccessPathand assertingvalue/matchValues/boundaryValue. Green: add theplatformoption, threaded to the normalizers. Commit:refactor(pi-permission-system): thread platform option through AccessPath factory (#510). -
feat:Introduce thePathNormalizercollaborator (not yet wired). Red:path-normalizer.test.ts(both flavors) coveringforPath/forLiteral/isAbsolute/resolveBase/joinBase/isWithinDirectory/isOutsideWorkingDirectory. Green: addsrc/path-normalizer.tswrapping the platform-parameterized primitives. Commit:feat(pi-permission-system): add PathNormalizer collaborator (#510). -
refactor:Build the normalizer at the session edge and expose it. Red:permission-sessiontest assertinggetPathNormalizer()returns a normalizer bound to the reset cwd; composition-root test asserting the single platform read flows to session + manager. Green:index.tsreadsprocess.platformonce and injects it;PermissionSessionconstructor takesplatform,resetForNewSessionbuilds the normalizer,getPathNormalizer()added to the class and theToolCallGateInputsinterface. Commit:refactor(pi-permission-system): construct PathNormalizer at the session edge (#510). -
refactor:Migrate the bash projection andBashProgram.parseonto the normalizer. Red/Green together (signature change breaks call sites in one commit):cwd-projection.tsandBashProgram.parsetake aPathNormalizerin place ofcwd; drop thenode:pathimport; update the twoparsecall sites (tool-call-gate-pipeline.ts,bash-path-extractor.ts) and migrateprogram.test.ts/bash-external-directory.test.tsfixtures (lift-and-shift: pass a host-defaultPathNormalizer). Add the end-to-endwin32-normalizer projection assertion. Commit:refactor(pi-permission-system): drive bash path projection through PathNormalizer (#510). -
refactor:Migrate the per-tool and external-directory path gates onto the session normalizer. Red/Green:path.ts,external-directory.ts,bash-external-directory.ts,bash-path.tsusegetPathNormalizer()/the threaded normalizer instead ofAccessPath.forPath({ cwd: tcc.cwd }); update gate tests. Commit:refactor(pi-permission-system): route path gates through the session PathNormalizer (#510). -
refactor:Inject the platform intorule.tsmatching. Red/Green: remove the= process.platformdefaults onevaluate/evaluateMostRestrictive/evaluateFirst;PermissionManagertakesplatformand supplies it; updaterule.test.ts/permission-managertests and thearchitecture.mdrule.tslisting if the signature note changes. Commit:refactor(pi-permission-system): inject platform into rule evaluation (#510). -
refactor:Inject the platform intosubagent-context.ts. Red/Green:normalizeFilesystemPath/isSubagentExecutionContextacceptplatformfrom the composition-root caller; remove the hand-rolled branches; updatesubagent-context.test.tsand the composition-root wiring. Commit:refactor(pi-permission-system): inject platform into subagent context detection (#510). -
build:Add theprocess.platformlint guard and remove the last interior defaults. Red: confirm the guard fires on a temporary interiorprocess.platform(sanity), then remove the now-unused injectable= process.platformdefaults onisPathWithinDirectory/isPiInfrastructureRead(all callers supply it). Green: add the scopedno-restricted-syntaxblock toeslint.config.js; runpnpm run lint+ the full package suite to confirmindex.tsis the only reader. Commit:build(pi-permission-system): forbid interior process.platform reads (#510). -
docs:Record the seam. Updatearchitecture.md(thePathNormalizerseam, its relationship to Phase 7 #505, a roadmap entry for #510) andSKILL.md(path/platform handling points atPathNormalizer; note the lint guard). Commit:docs(pi-permission-system): document the PathNormalizer platform seam (#510).
Risks and Mitigations
cwdsource change (per-call → baked). The pipeline currently readsctx.cwdon every tool call; baking it into the session normalizer assumescwdis stable within a session. In Pi a session is bound to one project directory andctx.cwdis that directory on every event (the package already treatssession.getRuntimeContext()?.cwdas the session cwd), so this holds. Mitigation: build/refresh the normalizer inresetForNewSession(which already runs on everysession_start, including/new//resume//fork), so a session switch rebinds it; pin with a composition-root test that the normalizer's cwd tracks the reset ctx.- Accidental Windows behavior change.
The point of the refactor is parity; the only POSIX-hard-coded drift (
isRelativeCandidate) is deliberately left for #508. Mitigation: the converted ops all previously used hostnode:path; the POSIX suite stays green untouched, and the newwin32tests assert the seam, not a host-default change. canonicalizePathwin32branch is newly reachable. Mitigation: it is a no-op on POSIX; thewin32unit tests validate the new branch in isolation before #508 exercises it end-to-end.- Large multi-step migration. Mitigation: lift-and-shift — additive seam first (steps 1–3), edge wiring (step 4), then consumer migration (steps 5–8) one surface per commit, each leaving the suite green; the lint guard (step 9) lands only after the last interior read is gone.
- Overlap with Phase 7 #505 (path-utils dissolution).
Mitigation:
PathNormalizeris a facade overpath-utils, not a relocation; #505 can later move the internals behind it without re-touching the seam.
Open Questions
None blocking.
The collaborator shape (single PathNormalizer owning construction + routing, name confirmed), the cwd-baked construction edge, the behavior-preserving scope (defer isRelativeCandidate to #508), and the full enforcement scope (lint guard + rule.ts/subagent-context cleanup) were confirmed with the operator during planning.