--- issue: 533 issue_title: 'Windows/Git Bash: POSIX paths like /dev/null and /tmp are normalized as C:\dev\null / C:\tmp' --- # Interpret POSIX-shaped bash tokens with Git Bash semantics on win32 ## Release Recommendation **Release:** ship independently This issue is not part of any architecture-roadmap phase (no `Release:` tag references it), and the change is a user-facing `fix:` for Windows Git Bash users, so it cuts its own release. ## Problem Statement On Windows, the permission system normalizes every bash path token with `node:path.win32` semantics. POSIX-shaped absolute tokens — which Git Bash interprets through its MSYS mount table — are thereby reinterpreted as native Windows paths the shell will never touch: - `/dev/null` becomes `c:\dev\null`, so `isSafeSystemPath()` never matches and `echo hi > /dev/null` triggers an `external_directory` prompt. - `/tmp` becomes `C:\tmp`, so prompts display a misleading path, and a user rule for the real `C:\tmp\*` directory can wrongly match Git Bash `/tmp` tokens (which actually point at `%TEMP%` under Git Bash). - `/c/Users/x` becomes `C:\c\Users\x`, so a project file referenced through the MSYS drive mount is wrongly flagged external. This contradicts the package's own documented contract (`docs/configuration.md`: "OS device paths (`/dev/null`, …) are always excluded"), so the change is a `fix:`, not a behavior redesign. The issue was filed by an external contributor (`ThreeIce`); the operator confirmed the direction via the planning-session decision gate. ## Goals - On win32, recognize the four safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) in bash commands so they never trigger `external_directory` prompts — matching the existing POSIX behavior and the documented contract. - On win32, translate MSYS drive-mount bash tokens (`/c/…`, `/d/…`) to their Windows equivalents (`C:\…`) so containment and rule matching operate on the path Git Bash will actually access. - On win32, treat all other POSIX-absolute bash tokens (`/tmp/foo`, `/usr/bin`) as literal-only external paths: always outside the working directory, matched and displayed exactly as typed, never fabricated into `C:\tmp\foo`. - Keep `cd`-folding coherent with the same semantics: `cd /c/x` folds to a translated known base; `cd /tmp` folds to the conservative unknown base. - Zero behavior change on POSIX platforms, and zero behavior change for tool-input paths (`read`/`write`/`edit`) on any platform. - Not breaking: behavior changes only on win32 where the current behavior contradicts the documented safe-path contract and MSYS shell semantics. ## Non-Goals - No `cygpath` shell-outs and no Git Bash/MSYS environment detection — resolution must stay deterministic (same policy + same input → same decision), and the permission system cannot know which bash Pi core resolved. - No resolution of `/tmp` to `%TEMP%`/`os.tmpdir()` — the target varies by bash flavor (Git Bash mounts `/tmp` to `%TEMP%`; MSYS2 mounts it to `\tmp`; Cygwin to its own root), so any concrete mapping would be wrong for some installs. - No MSYS install-root resolution for `/usr`, `/etc`, `/mingw64` — literal-only external handling covers them conservatively. - No UNC (`//server/share`) special-casing — such tokens fall into the literal-only external branch, which is conservative-correct. - No POSIX-token translation for tool-input paths on win32: Node's `fs` (which the built-in tools use) genuinely resolves `/dev/null` to `C:\dev\null` on Windows, so the current prompt for a tool-input `/dev/null` is correct and must stay (least privilege). - No change to `NUL`-token handling — Pi core already rewrites `> NUL` to `> /dev/null` before spawning Git Bash (`normalizeNulRedirects()`, pi#4751). - No parity change for `PermissionsService` RPC path queries: an external query for a POSIX-shaped path on win32 still answers with win32 semantics (a path query carries no bash-surface context); accepted inconsistency, revisit only if a consumer reports it. - No WSL considerations — WSL bash runs in a Linux environment with a Linux Pi process; this plan is about Git Bash/MSYS on a win32 host. ## Background Research findings that drive the design: 1. **Pi core always executes bash through Git Bash on Windows.** `pi/packages/coding-agent/src/utils/shell.ts` resolves the shell on win32 as custom `shellPath` → `%ProgramFiles%\Git\bin\bash.exe` → any `bash.exe` on PATH (MSYS2/Cygwin); there is no cmd/PowerShell branch. So every bash command this package gates on Windows runs with POSIX/MSYS path semantics. 2. **Pi core has committed to `/dev/null` as the canonical device-redirect form on win32.** Because MSYS does not recognize `NUL`, core's `normalizeNulRedirects()` (pi#4731 / pi#4751) rewrites `> NUL` → `> /dev/null` before spawning the shell — core actively produces the exact token this package currently mangles. 3. **MSYS mount semantics** for the shapes in question: `/dev/*` are runtime devices (never filesystem paths); `/c/…` is a deterministic drive mount (`C:\…`); `/tmp` and other POSIX absolutes resolve inside install-dependent mounts that this package cannot know deterministically. Relevant existing modules: - `src/access-intent/bash/bash-path-resolver.ts` — `BashPathResolver` walks the parsed bash AST and projects tokens into `externalPaths` (the `external_directory` surface) and `ruleCandidates` (the `path` surface); both projections build `AccessPath`s via `PathNormalizer.forPath`; `foldCd` folds literal `cd` targets via `normalizer.isAbsolute`/`joinBase`. - `src/path-normalizer.ts` — `PathNormalizer`, the single platform home ([#510]): platform + cwd baked in at the session edge; the bash resolver asks it all platform-dependent questions. - `src/access-intent/access-path.ts` — `AccessPath` value object ([#476]) with `forPath`/`forLiteral` factories and type-distinct lexical/canonical accessors ([#418]). - `src/access-intent/path-normalization.ts` — the representation primitives (`normalizePathForComparison`, `getPathPolicyValues`, `canonicalNormalizePathForComparison`). - `src/safe-system-paths.ts` — `SAFE_SYSTEM_PATHS` + `isSafeSystemPath`, consumed by `path-containment.ts` (canonical boundary check) and the resolver's unknown-base branch. - `src/access-intent/bash/token-classification.ts` — pure shape classifiers; `/dev/null` and `/tmp` already pass `classifyTokenAsPathCandidate` (leading `/`), so no classifier change is needed. - `src/handlers/gates/external-directory.ts` — the tool-input external-directory gate; a separate consumer of `normalizer.isOutsideWorkingDirectory` that this plan must not disturb. Constraint from AGENTS.md / package skill: never read `process.platform` in `src/` — the platform is injected, and Windows behavior is tested by constructing a `PathNormalizer("win32", …)`, never by `vi.mock("node:path")`. Constraint from the package skill: default to least privilege — the literal-only branch must stay conservative (always external, prompt under `external_directory: ask`). ## Design Overview The organizing principle: **on a win32 host, the bash surface's path semantics are MSYS, not win32** — because Pi core always spawns Git Bash there. Tool-input paths keep pure win32 semantics (Node `fs` semantics), so the interpretation layer hooks into the bash token pipeline only. ### Token interpretation table (win32 bash tokens) | Token shape | Git Bash meaning | New handling | | ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------ | | `/dev/null`, `/dev/std{in,out,err}` (exact) | MSYS runtime device | Device `AccessPath`: lexical = canonical = token → safe-path check matches, never external | | `/c/…`, `/d/…` (drive mount) | `C:\…` | Translate, then existing `forPath` win32 resolution; typed form kept as a match alias | | Other `/…` (POSIX absolute) | Install-dependent mount | Literal-only `AccessPath` ([#393] machinery): always external, matched/displayed as typed | | `C:\…`, `C:/…`, relative, `~/…` | Same as win32 | Unchanged — existing behavior ([#508], [#382]) | Case handling: device recognition is an exact match against the four lowercase literals (a non-matching `/DEV/NULL` or `/dev/random` falls into the conservative literal-only branch); drive-mount translation accepts either letter case. ### New collaborator surface A new pure module owns the shape knowledge: ```typescript // src/access-intent/bash/msys-bash-tokens.ts export type Win32BashTokenKind = | { kind: "device" } | { kind: "drive-mount"; windowsPath: string } | { kind: "posix-absolute" } | { kind: "plain" }; export function classifyWin32BashToken(token: string): Win32BashTokenKind; ``` `PathNormalizer` grows one factory method (and stays the only consumer of the classifier): ```typescript // PathNormalizer forBashToken(token: string, options?: { resolveBase?: string }): AccessPath { if (this.platform !== "win32") return this.forPath(token, options); const shape = classifyWin32BashToken(token); // device → AccessPath.forDevice(token) // drive-mount → this.forPath(shape.windowsPath, { ...options, literalAliases: [token] }) // posix-absolute → this.forLiteral(normalizePathPolicyLiteral(token)) // plain → this.forPath(token, options) } ``` `AccessPath` grows a `forDevice(devicePath)` factory (lexical = canonical = the device path, `matchValues()` = `[devicePath]`), and `forPath`/`getPathPolicyValues` accept an optional `literalAliases: readonly string[]` so a drive-mount token's typed form (`/c/Users/x`) stays matchable alongside the translated forms. ### Consumer call sites (Tell-Don't-Ask check) `BashPathResolver.projectExternalPaths` switches to the new factory and derives the external decision from the returned value object instead of re-normalizing the lexical string: ```typescript const accessPath = this.normalizer.forBashToken(candidate, { resolveBase }); const canonical = accessPath.boundaryValue(); const isExternal = canonical ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical) : true; // literal-only bash token: POSIX-absolute, foreign to the win32 cwd const dedupKey = canonical || accessPath.value(); ``` `isBoundaryOutsideWorkingDirectory(canonical)` is a thin `PathNormalizer` delegation to the existing pure `isPathOutsideWorkingDirectory(canonical, canonicalCwd, platform)` — it removes the current double derivation (`isOutsideWorkingDirectory(lexical)` re-canonicalizes a string the `AccessPath` already canonicalized) and gives the device branch its safe-path exclusion for free (`isSafeSystemPath` already runs inside the pure check). The existing `isOutsideWorkingDirectory(pathValue)` method stays for its other consumer (`handlers/gates/external-directory.ts`, tool inputs). `buildRuleCandidatePath` switches `forPath` → `forBashToken` (one-line change); the [#393] unknown-base literal branch stays first and unchanged. `foldCd` routes the literal `cd` target through the same semantics via one new normalizer query: ```typescript // PathNormalizer interpretBashCdTarget(target: string): | { kind: "absolute"; value: string } // POSIX: any absolute; win32: C:\…, C:/…, or translated /c/… | { kind: "relative" } | { kind: "unknown" }; // win32: POSIX-absolute non-mount (cd /tmp) — conservative ``` `cd /tmp && cat foo` therefore degrades to the existing `UNKNOWN_BASE` machinery: `foo` becomes a literal-only conservative candidate, which is exactly right — the permission system cannot know where Git Bash's `/tmp` lives. ### Edge cases - Bare `/c` or `/c/` → translates to `C:\`. - `/dev/null/sub`, `/dev/random` → not exact device matches → literal-only external (conservative, prompts). - `//server/share` → literal-only external (the bare-slash rejection in `rejectNonPathToken` only drops `/`, `//`, etc. with no content). - Dedup: two distinct literal-only paths previously both had `boundaryValue() === ""`; the `canonical || lexical` dedup key prevents the second from being silently dropped. - ISP check: `forBashToken` takes `(token, { resolveBase? })` — identical shape to `forPath`, no unused fields; `classifyWin32BashToken` takes only the token string. ### Behavior deltas (win32 only) | Command (cwd `C:\Projects\App`) | Before | After | | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------- | | `echo hi > /dev/null` | Prompt for `C:\dev\null` | No prompt (safe device) | | `ls /tmp` | Prompt displaying `C:\tmp` | Prompt displaying `/tmp`; `/tmp*`-style `external_directory` allow rules match as typed | | `cat /c/Projects/App/src/x` | Prompt for `C:\c\Projects\App\src\x` | No prompt (inside cwd after translation) | | `cat /c/Other/x` | Prompt for `C:\c\Other\x` | Prompt for `C:\Other\x`, `/c/Other/x` kept as a match alias | | `cat C:\Other\x` | Prompt | Unchanged ([#508]) | ## Module-Level Changes - `src/access-intent/bash/msys-bash-tokens.ts` (new) — `classifyWin32BashToken` + `Win32BashTokenKind`; pure string classification, no filesystem, no platform read. - `src/access-intent/access-path.ts` — add `AccessPath.forDevice(devicePath)`; add optional `literalAliases` to the `forPath` options, threaded to `getPathPolicyValues`. - `src/access-intent/path-normalization.ts` — add optional `literalAliases?: readonly string[]` to `PathPolicyValueOptions`; `getPathPolicyValues` appends them to the match set. - `src/path-normalizer.ts` — add `forBashToken`, `isBoundaryOutsideWorkingDirectory`, `interpretBashCdTarget`; no changes to existing methods. - `src/access-intent/bash/bash-path-resolver.ts` — `projectExternalPaths` and `buildRuleCandidatePath` call `forBashToken`; external decision via `isBoundaryOutsideWorkingDirectory(boundaryValue())` with literal-only → unconditionally external; dedup key `canonical || lexical`; `foldCd` uses `interpretBashCdTarget`. - `src/safe-system-paths.ts` — unchanged (the device factory and the pure boundary check both reuse `isSafeSystemPath`); `src/access-intent/bash/token-classification.ts` — unchanged (POSIX absolutes already classify as candidates). - `test/access-intent/bash/msys-bash-tokens.test.ts` (new) — classifier unit tests. - `test/access-intent/access-path.test.ts` — `forDevice` + `literalAliases` coverage. - `test/path-normalizer.test.ts` — `forBashToken` win32 flavor (device / drive-mount / posix-absolute / plain), POSIX delegation identity, `interpretBashCdTarget`. - `test/bash-external-directory.test.ts` — win32 end-to-end scenarios (extends the existing `Windows drive-letter paths (win32 semantics)` describe block pattern with an injected `PathNormalizer("win32", …)`). - `test/access-intent/bash/program.test.ts` — `cd /c/…` and `cd /tmp` folding scenarios on win32. - `docs/configuration.md` — extend the Windows paragraph (currently case-insensitivity only) with Git Bash semantics: device paths honored, `/c/` mounts translated, other POSIX absolutes matched as typed; reconcile the "OS device paths are always excluded" sentence with a note that this now holds on Windows too. - `docs/architecture/architecture.md` — module-tree entries: add `msys-bash-tokens.ts`; update `path-normalizer.ts`, `access-path.ts`, `path-normalization.ts`, and `bash-path-resolver.ts` entries for the new methods/options. - `docs/decisions/0003-git-bash-posix-path-semantics.md` (new ADR) — records the "bash surface is MSYS on win32" decision, the deterministic subset chosen (devices, drive mounts, literal-only absolutes), and the rejected alternatives (`cygpath`, `%TEMP%` mapping). - `.pi/skills/package-pi-permission-system/SKILL.md` — the `Windows and Git Bash` platform-facts section was added during planning; update its final sentence from "planned in" to the implemented behavior, and extend the bash-token platform note in `Notes for Agents` with the win32 POSIX-token branches. No exports are removed or renamed, so no removed-symbol grep is required. ## Test Impact Analysis 1. **New unit tests enabled:** the classifier is testable in isolation (pure string shapes); `forBashToken` is testable on a bare `PathNormalizer("win32", …)` without parsing bash or spinning gates — previously the only way to observe win32 bash-token semantics was through the full resolver. 2. **Redundant tests:** none removed. The existing win32 drive-letter tests ([#508]) and unknown-base literal tests ([#393]) pin invariants this change must preserve, not behavior it replaces. 3. **Tests that must stay as-is:** `test/bash-external-directory.test.ts` `Windows drive-letter paths (win32 semantics)` block (pins #508); `test/path-normalization.test.ts` win32 assertions for non-device tokens (generic win32 resolution is unchanged); `test/safe-system-paths.test.ts` (the set and predicate are untouched); [#418] symlink-alias tests (lexical ∪ canonical matching unchanged). ## Invariants at risk - **[#418] — `external_directory` patterns match the typed (lexical) form**, pinned by `test/bash-external-directory.test.ts` symlink/alias tests. The change only adds aliases (`literalAliases`) and never drops the lexical form. - **[#382] — win32 comparisons are case-insensitive**, pinned in `test/path-containment.test.ts`. The device branch bypasses lowercasing for four already-lowercase literals only; drive-mount translation feeds the existing lowercasing path. - **[#508] — Windows drive-letter tokens are gated**, pinned in `test/bash-external-directory.test.ts`. `forBashToken`'s `plain` branch must delegate to `forPath` unchanged. - **[#393] — unknown-base relative tokens stay literal-only**, pinned in resolver tests. The unknown-base branch runs before the new interpretation and is untouched. - **[#454] — cd-fold pipeline semantics**, pinned in `test/access-intent/bash/program.test.ts`. `foldCd`'s walk position is unchanged; only the target interpretation is delegated. - **Documented contract** — `docs/configuration.md` "OS device paths are always excluded": currently prose-only on win32; TDD cycle 1 adds the pinning test. - **Projection refactor equivalence** — replacing `isOutsideWorkingDirectory(lexical)` with the boundary-based check must be observationally equivalent on POSIX; the full existing resolver suite pins this. ## TDD Order 1. **Devices in bash on win32** — red: `forBashToken("/dev/null")` on a win32 normalizer returns a boundary value that `isSafeSystemPath` accepts, and a resolver test asserting `echo hi > /dev/null` yields no external paths on win32 (currently yields `c:\dev\null`); green: `AccessPath.forDevice`, `PathNormalizer.forBashToken` (device branch + delegate-to-`forPath` fallback), projection switch to `forBashToken` + `isBoundaryOutsideWorkingDirectory`. Covers: `test/access-intent/access-path.test.ts`, `test/path-normalizer.test.ts`, `test/bash-external-directory.test.ts`. Commit: `fix(pi-permission-system): recognize POSIX device paths in bash commands on win32 (#533)`. 2. **Drive-mount translation** — red: classifier tests for `/c/…`/`/C/…`/bare `/c` shapes, and resolver tests asserting `cat /c//x` is not external while `cat /c/Other/x` is external with the typed alias in `matchValues()`; green: `msys-bash-tokens.ts` classifier, `literalAliases` option on `getPathPolicyValues`/`forPath`, drive-mount branch in `forBashToken`. Covers: `test/access-intent/bash/msys-bash-tokens.test.ts`, `test/access-intent/access-path.test.ts`, `test/bash-external-directory.test.ts`. Commit: `fix(pi-permission-system): translate MSYS drive-mount bash tokens on win32 (#533)`. 3. **POSIX-absolute literal-only handling** — red: resolver tests asserting `ls /tmp` on win32 yields an external path whose `value()` is `/tmp` (not `c:\tmp`) with `matchValues()` `["/tmp"]`, plus a dedup test with two distinct literal-only paths; green: posix-absolute branch in `forBashToken`, unconditional-external for empty boundary values, dedup key `canonical || lexical`, `buildRuleCandidatePath` switch. Covers: `test/bash-external-directory.test.ts`, `test/path-normalizer.test.ts`. Commit: `fix(pi-permission-system): match Git Bash POSIX-absolute bash tokens as typed on win32 (#533)`. 4. **cd folding** — red: `cd /c/Other && cat x` resolves `x` against `C:\Other` on win32; `cd /tmp && cat foo` degrades to the unknown-base conservative flag; green: `interpretBashCdTarget` + `foldCd` delegation. Covers: `test/access-intent/bash/program.test.ts`, `test/path-normalizer.test.ts`. Commit: `fix(pi-permission-system): fold Git Bash cd targets with MSYS semantics on win32 (#533)`. 5. **Gate-level integration** — end-to-end win32 coverage through the external-directory bash gate: `> /dev/null` under `external_directory: {"*": "ask"}` produces no prompt; `ls /tmp` prompts displaying `/tmp`; an `external_directory` allow rule on `/tmp*` suppresses the prompt. Covers: `test/bash-external-directory.test.ts` (or the handler-level fixture file if gate wiring is needed). Commit: `test(pi-permission-system): cover Git Bash POSIX path gating end to end (#533)`. 6. **Docs** — `docs/configuration.md` Windows section, `docs/architecture/architecture.md` module-tree entries, new ADR `docs/decisions/0003-git-bash-posix-path-semantics.md`, package skill note. Commit: `docs(pi-permission-system): document Git Bash path semantics on Windows (#533)`. ## Risks and Mitigations - **Loosening on win32 (devices, in-cwd drive mounts no longer prompt).** Mitigated by exact-match device recognition (four literals, no globs, no case folding) and by translation feeding the existing containment logic; anything ambiguous falls into the conservative literal-only branch that always prompts. - **Regression in the projection refactor (boundary-based external check).** Mitigated by the full existing resolver/gate suite (POSIX and win32 blocks) staying green, and by keeping `isOutsideWorkingDirectory` untouched for the tool gate. - **`fallow dead-code` gate on intermediate commits.** New exports (`forDevice`, classifier) are wired within the same or next cycle; the CI gate runs on the pushed tree, where all exports have consumers. - **Wildcard-matcher case handling for literal aliases.** Cycle 3's red test asserts a `/tmp*` allow rule matches the typed token on win32; if the matcher's win32 case folding interferes, the fix lands inside that cycle rather than as a surprise later. - **tree-sitter parse shapes for redirect targets.** The issue's headline repro (`> /dev/null`) flows through `collectRedirectTokens`; cycle 1's resolver test uses the literal repro string per the package-skill rule (trace the token through the classifier first). ## Open Questions - Exact naming (`msys-bash-tokens.ts`, `forBashToken`, `interpretBashCdTarget`) may be refined during implementation; the seam placement (classifier module consumed only by `PathNormalizer`) is settled. - Whether the drive-mount typed alias needs lowercasing before it enters `matchValues()` on win32 (the wildcard matcher may already fold case); decided by cycle 2's red test. [#382]: https://github.com/gotgenes/pi-packages/issues/382 [#393]: https://github.com/gotgenes/pi-packages/issues/393 [#418]: https://github.com/gotgenes/pi-packages/issues/418 [#454]: https://github.com/gotgenes/pi-packages/issues/454 [#476]: https://github.com/gotgenes/pi-packages/issues/476 [#508]: https://github.com/gotgenes/pi-packages/issues/508 [#510]: https://github.com/gotgenes/pi-packages/issues/510