Files
my-pi/pi-permission-system/docs/plans/0533-win32-git-bash-posix-paths.md
T

252 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 `<msysroot>\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/<cwd>/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