Files
my-pi/pi-permission-system/docs/plans/0562-path-flavor-platform-language.md
T

27 KiB
Raw Blame History

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 single platform === "win32" decision, and thread it into the leaves in place of the raw platform string.
  • 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.Platform from every domain signature: after this change, no src/ module outside index.ts and the PathFlavor factory names platform.
  • 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; caseInsensitive derivations → 1; flat src/ root 62 → 59 (three leaves relocate into src/path/).

Non-Goals

  • Unifying subagent-context's bespoke prefix-containment onto PathFlavor.isWithin. That check (isPathWithinDirectoryForSubagent) uses a different algorithm than path.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 BashDialect collaborator out of the win32 flavor. Pi core guarantees bash-on-win32 is always Git Bash (no cmd/PowerShell branch in core's shell.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). Selects this.impl once, but also carries two this.platform !== "win32" guards in forBashToken / interpretBashCdTarget for Git Bash/MSYS semantics, and a usesWindowsSeparators() accessor.
  • src/access-intent/path-normalization.tsnormalizePathForComparison (resolve + normalize + fold), canonicalNormalizePathForComparison (+ realpath + fold), getPathPolicyValues / getAbsolutePathPolicyValues / getCwdRelativePathPolicyValues. Called by AccessPath.forPath.
  • src/path-containment.tsisPathWithinDirectory (the path.relative containment geometry) and isPathOutsideWorkingDirectory (geometry + isSafeSystemPath exclusion).
  • src/canonicalize-path.tscanonicalizePath (best-effort realpathSync walk using impl.parse/impl.sep/impl.join).
  • src/pi-infrastructure-read.tsisPiInfrastructureRead (re-derives the win32 match-options literal, calls isPathWithinDirectory).
  • src/authority/subagent-context.tsnormalizeFilesystemPath (normalize + fold) and isSubagentExecutionContext, plus a private prefix-containment helper.
  • src/rule.tspathMatchOptions (re-derives the match-options literal, gated by PATH_SURFACES) and the evaluate/evaluateFirst/evaluateAnyValue/evaluateMostRestrictive/ruleMatches family, all threaded platform.
  • src/permission-manager.ts — holds this.platform, relays it to the rule.ts family. Consumes ResolvedAccessIntent and must not import AccessPath (ADR-0002, enforced by a no-restricted-imports rule scoped to this file). PathFlavor is a plain value object, not AccessPath, so it is safe to consume here.
  • src/access-intent/bash/token-classification.tsclassifyTokenAsRuleCandidate takes a windowsSeparators boolean derived from PathNormalizer.usesWindowsSeparators() by bash-path-resolver.ts (#520).
  • src/index.ts — the sole process.platform reader (the ESLint no-restricted-syntax guard exempts only this file); threads hostPlatform into PermissionManager, PermissionSession (→ PathNormalizer), and SubagentDetection.

Constraints from AGENTS.md and the package skill that apply:

  • Platform handling has a single home (PathNormalizer); no src/ module reads process.platform (ESLint-guarded). PathFlavor becomes a second platform-semantics home, still fed exclusively from index.ts's one read.
  • To test Windows behavior on POSIX CI, inject a win32 flavor — never vi.mock("node:path"). The documented new PathNormalizer("win32", cwd) idiom becomes new PathNormalizer(win32PathFlavor, cwd).
  • permission-manager.ts stays string-based (ADR-0002); a PathFlavor import is allowed (the guard bans only access-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 — the PathFlavor interface, posixPathFlavor / win32PathFlavor singletons, pathFlavorForPlatform factory.
  • 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.tssrc/path/path-containment.ts. Delete the standalone isPathWithinDirectory export (geometry moves onto flavor.isWithin); keep isPathOutsideWorkingDirectory(canonicalPath, canonicalCwd, flavor).
  • src/canonicalize-path.tssrc/path/canonicalize-path.ts; platformflavor (flavor.impl).
  • src/pi-infrastructure-read.tssrc/path/pi-infrastructure-read.ts; platformflavor (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 exports platformflavor; normalizePathForComparison = lexical cleanup + flavor.comparable; canonicalNormalizePathForComparison = flavor.comparable + canonicalizePath + flavor.fold; getCwdRelativePathPolicyValues uses flavor.impl.relative + flavor.isWithin.
  • src/access-intent/access-path.tsAccessPath.forPath option platformflavor (its only caller is PathNormalizer.forPath).
  • src/path-normalizer.ts — constructor platformflavor; hold this.flavor; expose readonly flavor; drop both !== "win32" guards via flavor.bashTokenShape; delete usesWindowsSeparators(); relay this.flavor to every leaf; import the relocated leaves from #src/path/....
  • src/authority/subagent-context.tsnormalizeFilesystemPath and isSubagentExecutionContext platformflavor (flavor.impl, flavor.fold, flavor.impl.sep).
  • src/authority/subagent-detection.tsSubagentDetectionDeps.platformflavor: PathFlavor.
  • src/rule.tspathMatchOptions(surface, flavor) returns PATH_SURFACES.has(surface) ? flavor.matchOptions : undefined; ruleMatches / evaluate / evaluateFirst / evaluateAnyValue / evaluateMostRestrictive platformflavor.
  • src/permission-manager.ts — option platform?flavor?: PathFlavor (default posixPathFlavor); hold this.flavor; relay to the rule.ts family. Import #src/path/path-flavor (allowed by the no-restricted-imports guard, which bans only access-intent/access-path).
  • src/permission-session.ts — constructor platformflavor; build new PathNormalizer(flavor, "") and rebuild on activate.
  • src/access-intent/bash/token-classification.tsclassifyTokenAsRuleCandidate(token, flavor) via flavor.hasPathSeparator; delete RuleCandidateOptions.windowsSeparators (and the interface if it empties).
  • src/access-intent/bash/bash-path-resolver.ts — pass this.normalizer.flavor to the classifier; drop the usesWindowsSeparators() read.
  • src/index.ts — construct the flavor once; inject into PermissionManager, PermissionSession, SubagentDetection. hostPlatform stays only as the argument to pathFlavorForPlatform.

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.tsmakeRealSession's platform? override → flavor?: PathFlavor (or accept platform and map to pathFlavorForPlatform at the boundary); it builds a real PermissionManager + PermissionSession.
  • test/helpers/gate-fixtures.ts — the new PathNormalizer(process.platform, ...) construction → pathFlavorForPlatform(process.platform).
  • Every new PathNormalizer("win32"|"linux"|process.platform, cwd) site (~30 across test/) → the corresponding singleton (win32PathFlavor / posixPathFlavor) or pathFlavorForPlatform(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 ("every path-containment / path-normalization / pi-infrastructure-read / canonicalize-path / rule.ts / subagent-context.ts leaf takes an injected platform parameter"), the usesWindowsSeparators() reference in the bash external_directory note, and the "pass platform: 'win32'" test idiom → "pass win32PathFlavor". 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 the S3 Mermaid node); update the health-metric rows (platform === "win32" 13 → 1, flat src/ root 62 → 59, caseInsensitive derivations → 1); refresh any module-layout listing or narrative that names the relocated files or the platform threading. architecture.md inline-copies the rule.ts Rule/RuleOrigin/Ruleset types — 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 a PathFlavor but still not AccessPath, so the boundary holds. Add a clarifying sentence only if the ADR's wording implies the manager holds a raw platform.

Test Impact Analysis

  1. New unit tests the extraction enables (previously impractical): path-flavor.test.ts directly exercises fold, comparable, isWithin, hasPathSeparator, bashTokenShape, and matchOptions on both singletons — the win32 case/separator semantics that were previously only reachable transitively through AccessPath / gate tests now have a focused home.
  2. Tests that become redundant: the win32-specific assertions scattered in path-containment.test.ts (containment geometry) and portions of path-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 once path-flavor.test.ts owns it.
  3. Tests that must stay as-is: the PathNormalizer win32 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.ts and permission-manager-unified.test.ts (new PathNormalizer("win32", ...)). The fold moving onto flavor.fold/flavor.matchOptions must 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 by bash-external-directory.test.ts and msys-bash-tokens.test.ts; bashTokenShape dispatch must reproduce them exactly.
  • #520 — win32 backslash-relative token recognized as path-shaped. Pinned by the backslash-token cases in the bash path tests; hasPathSeparator replacing the windowsSeparators flag must keep the same classification.
  • #510/#505PathNormalizer is the single platform home fed from one process.platform read. Pinned by the ESLint no-restricted-syntax guard (still exempting only index.ts) and the test/composition-root.test.ts wiring 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 29 and are all live by step 8; do not expect a clean fallow run mid-sequence.

  1. Add PathFlavor (pure addition). Red: test/path/path-flavor.test.ts asserts fold / comparable / isWithin / hasPathSeparator / bashTokenShape / matchOptions / impl on win32PathFlavor and posixPathFlavor, plus pathFlavorForPlatform selection. Green: implement src/path/path-flavor.ts; export BashTokenShape from msys-bash-tokens.ts if needed. refactor(pi-permission-system): add PathFlavor value object.
  2. Relocate + migrate canonicalize-path. Move to src/path/, signature platformflavor; bridge its one caller (path-normalization) inline; move its test; update importers. refactor(pi-permission-system): thread PathFlavor through canonicalize-path.
  3. Relocate + migrate path-containment; move geometry onto flavor.isWithin. Delete standalone isPathWithinDirectory; migrate all callers (path-normalization, pi-infrastructure-read, path-normalizer) to flavor.isWithin (inline bridge where the holder still has platform); keep isPathOutsideWorkingDirectory(..., flavor); move test. refactor(pi-permission-system): move containment geometry onto PathFlavor.
  4. Migrate path-normalization + access-path.forPath. All five path-normalization exports → flavor (comparable/fold/impl); AccessPath.forPath option platformflavor; bridge the caller (PathNormalizer.forPath); update path-normalization tests. refactor(pi-permission-system): thread PathFlavor through path normalization.
  5. Relocate + migrate pi-infrastructure-read. Move to src/path/, platformflavor (matchOptions, isWithin); delete the re-derived match-options literal; bridge the caller; move test. refactor(pi-permission-system): thread PathFlavor through infrastructure-read.
  6. Migrate subagent-context + subagent-detection. Leaves → flavor (impl, fold, impl.sep); SubagentDetectionDeps.platformflavor; bridge index.ts's construction inline; update subagent tests. refactor(pi-permission-system): thread PathFlavor through subagent detection.
  7. Migrate rule.ts + permission-manager. pathMatchOptions and the evaluate family platformflavor; manager option platform?flavor? (default posixPathFlavor); migrate rule.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.
  8. Thread the flavor from index.ts; dissolve PathNormalizer's platform conditionals. index.ts constructs the flavor once and injects it into PermissionManager / PermissionSession / SubagentDetection; PermissionSession + PathNormalizer constructors platformflavor; PathNormalizer exposes readonly flavor, drops both !== "win32" guards via bashTokenShape; remove every inline pathFlavorForPlatform(platform) bridge from steps 27; update all ~30 PathNormalizer test constructors, session-fixtures, gate-fixtures, and composition-root.test.ts. refactor(pi-permission-system): inject PathFlavor from the composition root.
  9. Migrate the bash rule-candidate classifier; delete usesWindowsSeparators. classifyTokenAsRuleCandidate(token, flavor) via flavor.hasPathSeparator; bash-path-resolver passes this.normalizer.flavor; delete usesWindowsSeparators() and RuleCandidateOptions.windowsSeparators; update classifier + resolver tests. refactor(pi-permission-system): answer path-separator syntax on PathFlavor.
  10. 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, ~30 PathNormalizer constructors). 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 fallow run. 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 for pathFlavorForPlatform( outside index.ts, the factory, and tests must return nothing at the end.
  • permission-manager.ts accidentally importing AccessPath while adding the PathFlavor import. Mitigation: the no-restricted-imports rule already bans it; PathFlavor lives in src/path/, a different module, so the guard is untouched.

Open Questions

  • None blocking. The subagent-context containment unification is deferred by design to #571; the BashDialect split is track-and-watch (Non-Goals).