27 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 562 | Leaf path modules re-derive the win32 path flavor from a raw platform parameter |
PathFlavor: pass the platform's path language, not the raw discriminator
Release Recommendation
Release: ship independently
This is Step 3 of the pi-permission-system Phase 10 roadmap, tagged Release: independent (a member of no batch).
It is a behavior-preserving refactor, so every implementation commit is refactor: (a hidden changelog type) — the work lands on main and auto-batches into the next feat:/fix: release rather than cutting one of its own (Refs #479).
The closing documentation commit touches only release-excluded paths (docs/architecture, .pi/), so it does not cut a release either.
Problem Statement
The win32-vs-POSIX path flavor is re-derived from a raw platform: NodeJS.Platform parameter at 13 sites across the package.
Six leaf functions open with the identical const impl = platform === "win32" ? winPath : posixPath ternary; the win32 case fold (toLowerCase()), the separator pick, and the { caseInsensitive: true, windowsSeparators: true } match-options literal are each independently re-derived at further sites.
The variant set is closed (win32 vs. POSIX), so the risk is not variant growth — it is connascence of algorithm: every site must re-derive the same mapping identically, and in a permission system a leaf that misses the case fold or separator fold is a silent bypass (the #382/#508 bug class).
The #505/#510 seam made the leaves pure platform-parameterized functions behind the PathNormalizer facade — it fixed where the platform is read but threaded the raw discriminator rather than the resolved product, so each leaf still re-interprets it.
Goals
- Introduce
PathFlavor, a value object that is the resolved product of the singleplatform === "win32"decision, and thread it into the leaves in place of the rawplatformstring. - Make the #382/#508 must-agree bug class structurally impossible: the case fold, the containment geometry, and the match-options each have exactly one implementation, owned by the flavor.
- Remove
NodeJS.Platformfrom every domain signature: after this change, nosrc/module outsideindex.tsand thePathFlavorfactory namesplatform. - Preserve behavior exactly — identical decisions on every input, on both platforms. This is not a breaking change.
- Advance the roadmap's structural metrics:
platform === "win32"sites 13 → 1;caseInsensitivederivations → 1; flatsrc/root 62 → 59 (three leaves relocate intosrc/path/).
Non-Goals
- Unifying
subagent-context's bespoke prefix-containment ontoPathFlavor.isWithin. That check (isPathWithinDirectoryForSubagent) uses a different algorithm thanpath.relative-based containment and diverges on..segments and cross-root paths, so collapsing it is behavior-affecting, not a pure refactor. Filed as a follow-up: #571. - Splitting a separate
BashDialectcollaborator out of the win32 flavor. Pi core guarantees bash-on-win32 is always Git Bash (no cmd/PowerShell branch in core'sshell.ts), so the win32⇔MSYS pairing is fixed; a second object would be polymorphism over an axis that cannot vary independently. Track-and-watch: extract it only if core ever un-fixes the pairing (WSL, PowerShell). - Any change to config, schema, the permission model, or user-facing docs — this is an internal structural refactor with no observable surface change.
Background
The relevant modules and their current platform coupling:
src/path-normalizer.ts— the facade constructed at the session edge with(platform, cwd). Selectsthis.implonce, but also carries twothis.platform !== "win32"guards inforBashToken/interpretBashCdTargetfor Git Bash/MSYS semantics, and ausesWindowsSeparators()accessor.src/access-intent/path-normalization.ts—normalizePathForComparison(resolve + normalize + fold),canonicalNormalizePathForComparison(+ realpath + fold),getPathPolicyValues/getAbsolutePathPolicyValues/getCwdRelativePathPolicyValues. Called byAccessPath.forPath.src/path-containment.ts—isPathWithinDirectory(thepath.relativecontainment geometry) andisPathOutsideWorkingDirectory(geometry +isSafeSystemPathexclusion).src/canonicalize-path.ts—canonicalizePath(best-effortrealpathSyncwalk usingimpl.parse/impl.sep/impl.join).src/pi-infrastructure-read.ts—isPiInfrastructureRead(re-derives the win32 match-options literal, callsisPathWithinDirectory).src/authority/subagent-context.ts—normalizeFilesystemPath(normalize + fold) andisSubagentExecutionContext, plus a private prefix-containment helper.src/rule.ts—pathMatchOptions(re-derives the match-options literal, gated byPATH_SURFACES) and theevaluate/evaluateFirst/evaluateAnyValue/evaluateMostRestrictive/ruleMatchesfamily, all threadedplatform.src/permission-manager.ts— holdsthis.platform, relays it to therule.tsfamily. ConsumesResolvedAccessIntentand must not importAccessPath(ADR-0002, enforced by ano-restricted-importsrule scoped to this file).PathFlavoris a plain value object, notAccessPath, so it is safe to consume here.src/access-intent/bash/token-classification.ts—classifyTokenAsRuleCandidatetakes awindowsSeparatorsboolean derived fromPathNormalizer.usesWindowsSeparators()bybash-path-resolver.ts(#520).src/index.ts— the soleprocess.platformreader (the ESLintno-restricted-syntaxguard exempts only this file); threadshostPlatformintoPermissionManager,PermissionSession(→PathNormalizer), andSubagentDetection.
Constraints from AGENTS.md and the package skill that apply:
- Platform handling has a single home (
PathNormalizer); nosrc/module readsprocess.platform(ESLint-guarded).PathFlavorbecomes a second platform-semantics home, still fed exclusively fromindex.ts's one read. - To test Windows behavior on POSIX CI, inject a win32 flavor — never
vi.mock("node:path"). The documentednew PathNormalizer("win32", cwd)idiom becomesnew PathNormalizer(win32PathFlavor, cwd). permission-manager.tsstays string-based (ADR-0002); aPathFlavorimport is allowed (the guard bans onlyaccess-intent/access-path).
Design Overview
The PathFlavor value object
PathFlavor is the platform's path language expressed as one object: syntax recognition, token semantics, and an equivalence relation — the three things the leaves currently re-derive.
It is pure (no filesystem access) and immutable; two cached singletons are selected by a factory that holds the package's one remaining === "win32" comparison.
// src/path/path-flavor.ts
import type { PlatformPath } from "node:path";
import type { BashTokenShape } from "#src/access-intent/bash/msys-bash-tokens";
import type { WildcardMatchOptions } from "#src/wildcard-matcher";
export interface PathFlavor {
/** Node's own platform strategy (path.win32 | path.posix). Path-domain primitives use it directly. */
readonly impl: PlatformPath;
/** Win32 { caseInsensitive, windowsSeparators } | undefined — the match-options product for the wildcard engine. */
readonly matchOptions: WildcardMatchOptions | undefined;
/** Comparison case fold: win32 => value.toLowerCase(), posix => value. */
fold(value: string): string;
/** resolve + normalize + fold against a base — the #382 invariant's single home. */
comparable(pathValue: string, base: string): string;
/** path.relative-based containment geometry. */
isWithin(pathValue: string, directory: string): boolean;
/** True when the token contains a path separator under this platform (posix: "/"; win32: "/" or "\"). */
hasPathSeparator(token: string): boolean;
/** MSYS/Git-Bash token shape on win32; always { kind: "plain" } on posix. */
bashTokenShape(token: string): BashTokenShape;
}
export const posixPathFlavor: PathFlavor;
export const win32PathFlavor: PathFlavor;
/** The one `platform === "win32"` decision in the package. */
export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor;
Ownership rule (prevents god-object drift): the flavor owns platform semantics (fold, geometry, token shape, match options, separator syntax).
Domain policy stays in the functions that consume it — the lexical cleanup in normalizePathForComparison (trim / strip quotes / strip @ / expandHome), the alias generation in getPathPolicyValues, the isSafeSystemPath exclusion in isPathOutsideWorkingDirectory, the infra-read rules, and rule.ts's PATH_SURFACES-gated dispatch.
impl is exposed rather than wrapped: post-migration its consumers are exclusively path-domain primitives, and PlatformPath is itself a strategy object Node maintains — wrapping it would add ~7 forwarding methods with no semantics.
The doc comment states the ownership rule; sealing it later (make it private, mirror the used methods) is a two-line change if it ever itches.
How the flavor dissolves each site
fold / comparable / matchOptions collapse the equivalence family — the #382/#508 must-agree class.
A leaf can no longer re-derive-and-diverge because it never derives at all:
// path-normalization.ts — after
export function normalizePathForComparison(pathValue: string, base: string, flavor: PathFlavor): string {
const cleaned = lexicalCleanup(pathValue); // domain policy stays here
return cleaned ? flavor.comparable(cleaned, base) : "";
}
bashTokenShape dissolves PathNormalizer's two !== "win32" guards into uniform dispatch — the posix flavor returns { kind: "plain" } (semantically correct: every posix token is an ordinary path), so the switch runs unchanged on both platforms:
// path-normalizer.ts — after; no platform conditional remains
forBashToken(token: string, options?: { resolveBase?: string }): AccessPath {
const shape = this.flavor.bashTokenShape(token);
switch (shape.kind) {
case "device": return AccessPath.forDevice(token);
case "drive-mount": return this.forPath(shape.windowsPath, options);
case "posix-absolute": { /* literal-only + backslash alias, unchanged */ }
case "plain": return this.forPath(token, options);
}
}
hasPathSeparator dissolves the ask-leak in the bash rule-candidate classifier — the resolver stops asking usesWindowsSeparators() and relaying a boolean through an options bag:
// token-classification.ts — after; the two includes() lines become one
if (token.startsWith(".")) return token;
if (flavor.hasPathSeparator(token)) return token; // was: includes("/") + (windowsSeparators && includes("\\"))
if (token.includes("..")) return token;
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
PathNormalizer exposes its flavor (readonly flavor: PathFlavor) so bash-path-resolver.ts passes this.normalizer.flavor to the classifier; PathNormalizer.usesWindowsSeparators() and RuleCandidateOptions.windowsSeparators are deleted.
Construction and threading
index.ts performs the one process.platform read, resolves it into the flavor once, and injects that collaborator into the three holders — "instantiate the right collaborator as soon as we know the platform":
// index.ts
const flavor = pathFlavorForPlatform(process.platform);
const permissionManager = new PermissionManager({ agentDir, flavor, isYoloEnabled });
const subagentDetection = new SubagentDetection({ subagentSessionsDir, flavor, registry });
// session ctor: new PermissionSession(..., flavor) -> new PathNormalizer(flavor, cwd)
PathNormalizer and PermissionManager hold this.flavor and drop their platform fields; PermissionManager's constructor option becomes flavor?: PathFlavor (defaulting to posixPathFlavor, mirroring the old platform ?? "linux"); SubagentDetection's dep becomes flavor: PathFlavor.
Lift-and-shift bridge
Migrating a leaf's signature from platform to PathFlavor breaks its callers at the type level.
To keep every commit compiling and green, the migration is bottom-up: each leaf switches to PathFlavor first, and its not-yet-migrated callers bridge with an inline pathFlavorForPlatform(platform) at the call site.
Because pathFlavorForPlatform returns cached singletons, the bridge is cheap and cannot diverge — the transitional state is still bypass-safe.
The final threading step removes every inline bridge once the holders carry the flavor directly.
Module-Level Changes
New:
src/path/path-flavor.ts— thePathFlavorinterface,posixPathFlavor/win32PathFlavorsingletons,pathFlavorForPlatformfactory.test/path/path-flavor.test.ts— unit tests for every capability, on both flavors.
Relocated into src/path/ (tidy-first — these leaves reach their final home; three fewer files at the flat src/ root, 62 → 59):
src/path-containment.ts→src/path/path-containment.ts. Delete the standaloneisPathWithinDirectoryexport (geometry moves ontoflavor.isWithin); keepisPathOutsideWorkingDirectory(canonicalPath, canonicalCwd, flavor).src/canonicalize-path.ts→src/path/canonicalize-path.ts;platform→flavor(flavor.impl).src/pi-infrastructure-read.ts→src/path/pi-infrastructure-read.ts;platform→flavor(flavor.matchOptions,flavor.isWithin); delete the re-derived match-options literal.- Move each module's test to
test/path/and update the#src/...import.
Edited (signature/body, no relocation):
src/access-intent/path-normalization.ts— all five exportsplatform→flavor;normalizePathForComparison= lexical cleanup +flavor.comparable;canonicalNormalizePathForComparison=flavor.comparable+canonicalizePath+flavor.fold;getCwdRelativePathPolicyValuesusesflavor.impl.relative+flavor.isWithin.src/access-intent/access-path.ts—AccessPath.forPathoptionplatform→flavor(its only caller isPathNormalizer.forPath).src/path-normalizer.ts— constructorplatform→flavor; holdthis.flavor; exposereadonly flavor; drop both!== "win32"guards viaflavor.bashTokenShape; deleteusesWindowsSeparators(); relaythis.flavorto every leaf; import the relocated leaves from#src/path/....src/authority/subagent-context.ts—normalizeFilesystemPathandisSubagentExecutionContextplatform→flavor(flavor.impl,flavor.fold,flavor.impl.sep).src/authority/subagent-detection.ts—SubagentDetectionDeps.platform→flavor: PathFlavor.src/rule.ts—pathMatchOptions(surface, flavor)returnsPATH_SURFACES.has(surface) ? flavor.matchOptions : undefined;ruleMatches/evaluate/evaluateFirst/evaluateAnyValue/evaluateMostRestrictiveplatform→flavor.src/permission-manager.ts— optionplatform?→flavor?: PathFlavor(defaultposixPathFlavor); holdthis.flavor; relay to therule.tsfamily. Import#src/path/path-flavor(allowed by theno-restricted-importsguard, which bans onlyaccess-intent/access-path).src/permission-session.ts— constructorplatform→flavor; buildnew PathNormalizer(flavor, "")and rebuild onactivate.src/access-intent/bash/token-classification.ts—classifyTokenAsRuleCandidate(token, flavor)viaflavor.hasPathSeparator; deleteRuleCandidateOptions.windowsSeparators(and the interface if it empties).src/access-intent/bash/bash-path-resolver.ts— passthis.normalizer.flavorto the classifier; drop theusesWindowsSeparators()read.src/index.ts— construct the flavor once; inject intoPermissionManager,PermissionSession,SubagentDetection.hostPlatformstays only as the argument topathFlavorForPlatform.
Verify BashTokenShape is exported from src/access-intent/bash/msys-bash-tokens.ts for path-flavor.ts to import (it holds the discriminated union classifyWin32BashToken returns); export it if it is currently local.
Test fixtures/harnesses:
test/helpers/session-fixtures.ts—makeRealSession'splatform?override →flavor?: PathFlavor(or acceptplatformand map topathFlavorForPlatformat the boundary); it builds a realPermissionManager+PermissionSession.test/helpers/gate-fixtures.ts— thenew PathNormalizer(process.platform, ...)construction →pathFlavorForPlatform(process.platform).- Every
new PathNormalizer("win32"|"linux"|process.platform, cwd)site (~30 acrosstest/) → the corresponding singleton (win32PathFlavor/posixPathFlavor) orpathFlavorForPlatform(process.platform). test/rule.test.ts(49 platform references) and the manager tests → flavor singletons.
Documentation (final commit; all release-excluded, so no release impact):
.pi/skills/package-pi-permission-system/SKILL.md— rework the prose naming the removed/changed mechanisms: the leaf list ("everypath-containment/path-normalization/pi-infrastructure-read/canonicalize-path/rule.ts/subagent-context.tsleaf takes an injectedplatformparameter"), theusesWindowsSeparators()reference in the bashexternal_directorynote, and the "passplatform: 'win32'" test idiom → "passwin32PathFlavor". These are reworded-prose updates carrying no removed symbol, so grep the skill for each mechanism name.packages/pi-permission-system/docs/architecture/architecture.md— mark Step 3 complete (✅on the step heading and theS3Mermaid node); update the health-metric rows (platform === "win32"13 → 1, flatsrc/root 62 → 59,caseInsensitivederivations → 1); refresh any module-layout listing or narrative that names the relocated files or theplatformthreading.architecture.mdinline-copies therule.tsRule/RuleOrigin/Rulesettypes — those are unchanged (only function signatures change), so that listing needs no edit; confirm during the pass.packages/pi-permission-system/docs/decisions/0002-path-values-string-boundary.md— confirm it stays accurate; the manager now consumes aPathFlavorbut still notAccessPath, so the boundary holds. Add a clarifying sentence only if the ADR's wording implies the manager holds a rawplatform.
Test Impact Analysis
- New unit tests the extraction enables (previously impractical):
path-flavor.test.tsdirectly exercisesfold,comparable,isWithin,hasPathSeparator,bashTokenShape, andmatchOptionson both singletons — the win32 case/separator semantics that were previously only reachable transitively throughAccessPath/ gate tests now have a focused home. - Tests that become redundant: the win32-specific assertions scattered in
path-containment.test.ts(containment geometry) and portions ofpath-normalization.test.ts(fold behavior) overlap with the new flavor tests. Keep them for now — they exercise the leaf functions' domain-policy wrapping (lexical cleanup, alias generation), not the flavor's raw geometry — but simplify any assertion that only re-checks the fold oncepath-flavor.test.tsowns it. - Tests that must stay as-is: the
PathNormalizerwin32 tests (path-normalizer.test.ts), the bash MSYS token tests (msys-bash-tokens.test.ts,bash-external-directory.test.ts), and the gate acceptance tests genuinely exercise the composed behavior (token shape → AccessPath → decision) and pin the #533/#520 semantics end-to-end; they only swap their construction idiom to the flavor singleton.
Invariants at risk
This change touches surfaces earlier phases refactored; each documented outcome must stay green:
- #382/#508 — win32 case/separator fold on path-surface matching.
Pinned by the win32 path-matching tests in
rule.test.tsandpermission-manager-unified.test.ts(new PathNormalizer("win32", ...)). The fold moving ontoflavor.fold/flavor.matchOptionsmust not change any decision — these tests are the guard. - #533 — Git Bash/MSYS bash-token semantics (safe devices preserved,
/c/mounts translated, other POSIX absolutes literal-only). Pinned bybash-external-directory.test.tsandmsys-bash-tokens.test.ts;bashTokenShapedispatch must reproduce them exactly. - #520 — win32 backslash-relative token recognized as path-shaped.
Pinned by the backslash-token cases in the bash path tests;
hasPathSeparatorreplacing thewindowsSeparatorsflag must keep the same classification. - #510/#505 —
PathNormalizeris the single platform home fed from oneprocess.platformread. Pinned by the ESLintno-restricted-syntaxguard (still exempting onlyindex.ts) and thetest/composition-root.test.tswiring tests.
No new test is needed — each invariant already lives in a test, not only prose.
TDD Order
Every step below is behavior-preserving; each keeps the suite green and compiling.
Commit type is refactor: throughout (the closing docs commit is docs:), so nothing cuts a release on its own.
pnpm fallow dead-code is a final-state gate — the flavor's methods are consumed incrementally across steps 2–9 and are all live by step 8; do not expect a clean fallow run mid-sequence.
- Add
PathFlavor(pure addition). Red:test/path/path-flavor.test.tsassertsfold/comparable/isWithin/hasPathSeparator/bashTokenShape/matchOptions/implonwin32PathFlavorandposixPathFlavor, pluspathFlavorForPlatformselection. Green: implementsrc/path/path-flavor.ts; exportBashTokenShapefrommsys-bash-tokens.tsif needed.refactor(pi-permission-system): add PathFlavor value object. - Relocate + migrate
canonicalize-path. Move tosrc/path/, signatureplatform→flavor; bridge its one caller (path-normalization) inline; move its test; update importers.refactor(pi-permission-system): thread PathFlavor through canonicalize-path. - Relocate + migrate
path-containment; move geometry ontoflavor.isWithin. Delete standaloneisPathWithinDirectory; migrate all callers (path-normalization,pi-infrastructure-read,path-normalizer) toflavor.isWithin(inline bridge where the holder still hasplatform); keepisPathOutsideWorkingDirectory(..., flavor); move test.refactor(pi-permission-system): move containment geometry onto PathFlavor. - Migrate
path-normalization+access-path.forPath. All fivepath-normalizationexports →flavor(comparable/fold/impl);AccessPath.forPathoptionplatform→flavor; bridge the caller (PathNormalizer.forPath); updatepath-normalizationtests.refactor(pi-permission-system): thread PathFlavor through path normalization. - Relocate + migrate
pi-infrastructure-read. Move tosrc/path/,platform→flavor(matchOptions,isWithin); delete the re-derived match-options literal; bridge the caller; move test.refactor(pi-permission-system): thread PathFlavor through infrastructure-read. - Migrate
subagent-context+subagent-detection. Leaves →flavor(impl,fold,impl.sep);SubagentDetectionDeps.platform→flavor; bridgeindex.ts's construction inline; update subagent tests.refactor(pi-permission-system): thread PathFlavor through subagent detection. - Migrate
rule.ts+permission-manager.pathMatchOptionsand theevaluatefamilyplatform→flavor; manager optionplatform?→flavor?(defaultposixPathFlavor); migraterule.test.ts(49 sites) and the manager tests to the singletons via a lift-and-shift within the step.refactor(pi-permission-system): thread PathFlavor through rule evaluation. - Thread the flavor from
index.ts; dissolvePathNormalizer's platform conditionals.index.tsconstructs the flavor once and injects it intoPermissionManager/PermissionSession/SubagentDetection;PermissionSession+PathNormalizerconstructorsplatform→flavor;PathNormalizerexposesreadonly flavor, drops both!== "win32"guards viabashTokenShape; remove every inlinepathFlavorForPlatform(platform)bridge from steps 2–7; update all ~30PathNormalizertest constructors,session-fixtures,gate-fixtures, andcomposition-root.test.ts.refactor(pi-permission-system): inject PathFlavor from the composition root. - Migrate the bash rule-candidate classifier; delete
usesWindowsSeparators.classifyTokenAsRuleCandidate(token, flavor)viaflavor.hasPathSeparator;bash-path-resolverpassesthis.normalizer.flavor; deleteusesWindowsSeparators()andRuleCandidateOptions.windowsSeparators; update classifier + resolver tests.refactor(pi-permission-system): answer path-separator syntax on PathFlavor. - Documentation.
Update the package skill's reworded prose and the architecture roadmap (Step 3
✅+ Mermaid node + health metrics); confirm ADR-0002 accuracy.docs(pi-permission-system): record PathFlavor and complete roadmap Step 3.
Risks and Mitigations
- Silent decision drift (the exact bug class this fixes). Mitigation: behavior-preserving throughout, guarded by the #382/#508/#533/#520 invariant tests listed above; each step keeps the suite green before committing.
- Large mechanical test migration (
rule.test.ts, ~30PathNormalizerconstructors). Mitigation: lift-and-shift with the cached-singleton bridge so no step rewrites a whole test file at once; the singleton swap is a construction-idiom change, not a behavior change. - Transitional dead code failing a mid-sequence
fallowrun. Mitigation: treat fallow as a final-state gate (documented above); the pre-completion reviewer runs it once at the end when every flavor method is live. - Intermediary inline
pathFlavorForPlatform(platform)bridges left behind. Mitigation: step 8 explicitly removes every bridge; a grep forpathFlavorForPlatform(outsideindex.ts, the factory, and tests must return nothing at the end. permission-manager.tsaccidentally importingAccessPathwhile adding thePathFlavorimport. Mitigation: theno-restricted-importsrule already bans it;PathFlavorlives insrc/path/, a different module, so the guard is untouched.
Open Questions
- None blocking.
The
subagent-contextcontainment unification is deferred by design to #571; theBashDialectsplit is track-and-watch (Non-Goals).