--- issue: 508 issue_title: "fix: bash external_directory gate misses Windows drive-letter absolute paths" --- # Recognize Windows drive-letter paths in the bash path classifiers ## Release Recommendation **Release:** ship independently This fix is a standalone bug fix, not part of a release batch. It ships on its own `fix:` release. [#510] has **landed** (the `PathNormalizer` platform/path-semantics seam), so #508 builds directly on it rather than carrying its own platform plumbing. The trailing `docs:` commit is a hidden changelog type that batches into the same release. ## Problem Statement On Windows / MSYS2, a bash command that references a file by a native drive-letter absolute path bypasses the `external_directory` gate, while the same file accessed through the `read` / `write` tools is correctly gated. The strict path-candidate classifier `classifyTokenAsPathCandidate` (`src/access-intent/bash/token-classification.ts`) — the one feeding the `external_directory` bash gate — only recognizes Unix-style absolute paths (`/…`), home-relative paths (`~/…`), and parent-traversal paths (`..`). A Windows drive-letter path starts with a letter, so it is silently dropped before the gate ever sees it. Two sub-cases differ in which surfaces miss them: - `C:/Windows/win.ini` (forward slashes) is dropped by the strict classifier but already accepted by the broader `path` classifier (`classifyTokenAsRuleCandidate`, because it contains `/`). So it is missed only by the `external_directory` gate. - `D:\secrets\password.txt` (backslashes) contains no `/`, no leading `.`, and no `..`, so it is dropped by **both** classifiers. The format used to reference a path should not be a way to escape the working-directory boundary check. ## Goals - The strict classifier recognizes Windows drive-letter absolute paths in both separator forms (`C:/…` and `C:\…`), so the `external_directory` bash gate sees them. - The broader classifier also recognizes the backslash drive form (`D:\…`), so a `path`-surface rule applies to drive paths consistently across separator forms (the forward-slash form already reaches it via the `/` branch). - The fix lands on [#510]'s `PathNormalizer` seam: absoluteness, containment, and canonicalization are decided by the baked-in platform flavor, so the newly-admitted drive tokens route correctly on Windows (absolute → outside-CWD check) and on POSIX (relative → in-CWD gating) with no per-#508 platform plumbing. The one residual hand-rolled check #510 left behind — `isRelativeCandidate` in `bash-path-resolver.ts` — is converted to the normalizer's `isAbsolute` here (see Design). - POSIX behavior is unchanged: a drive-shaped token resolves as the real in-CWD relative path it denotes (`cat C:/foo` → `./C:/foo`) and stays gated by the `path` surface as it is today. This is a non-breaking bug fix (`fix:`). It closes a permission bypass; the gate's documented intent already covers these paths. On POSIX there is no observable change; on Windows a previously-ungated drive path now correctly triggers the `external_directory` prompt — the intended behavior of the gate. ## Non-Goals - The platform/path-semantics seam itself (the `PathNormalizer` collaborator threaded through the projection, `AccessPath`, `normalizePathForComparison`, `canonicalizePath`) is [#510], which has landed. #508 builds on it. The lone exception is the `isRelativeCandidate` free function in `bash-path-resolver.ts`, whose `startsWith("/")` → `isAbsolute` conversion #510 deferred; #508 folds that one change in (it is required to avoid the unknown-base over-flag once drive tokens are admitted). - The bare-filename `path`-surface gap (`cat id_rsa`, `cat key.pem`) is tracked separately by [#509] and is out of scope here. - No POSIX advisory warning for "this token looks like a Windows path on a POSIX host." Dropping such a token would *reduce* POSIX path-surface coverage of the real in-CWD access; the focused fix keeps gating it. Not filed as a follow-up — no concrete need named. - `token-collection.ts`, `command-enumeration.ts`, and `program.ts` logic is unchanged by #508 — the fix is confined to classifier shape recognition plus the single `isRelativeCandidate` conversion in `bash-path-resolver.ts`. ## Background Relevant modules (`src/access-intent/bash/`): - `token-classification.ts` — two pure classifiers (`classifyTokenAsPathCandidate` strict, `classifyTokenAsRuleCandidate` broad) sharing a private `rejectNonPathToken` prelude. Pure string-shape matching, no platform branch (Refs [#289], [#476]). - `bash-path-resolver.ts` (the post-[#510] class form of the old `cwd-projection.ts`) — the `BashPathResolver` class holds a `PathNormalizer` (`this.normalizer`) and exposes `projectExternalPaths` (feeds `external_directory`, strict classifier) and `projectRuleCandidates` (feeds the `path` surface, broad classifier). Its `cd`-fold (`foldCd`) already delegates absoluteness to `this.normalizer.isAbsolute`, but the module-level free function `isRelativeCandidate` (called from `projectExternalPaths` and `buildRuleCandidatePath`) still hand-rolls `!candidate.startsWith("/") && !candidate.startsWith("~")`. The literal-only guard for a relative candidate under an unknown `cd` base (Refs [#393]) lives in `buildRuleCandidatePath`. Key existing facts that shape the design: - The platform-sensitive decision ("is `C:/foo` absolute?") lives in `node:path` (`path.win32.isAbsolute("C:/foo") === true`, `path.posix.isAbsolute("C:/foo") === false`) and is reached through the `PathNormalizer`'s `isAbsolute`. The bug #508 fixes is purely that the strict classifier drops the token before resolution. - The `rejectNonPathToken` prelude already lets drive paths through: `C:/Windows/win.ini` and `D:\secrets\password.txt` survive it (the `URL_PATTERN` requires `://`, so a single-slash `C:/…` is not a URL; backslash paths contain no metacharacter sequence). Only the **acceptance gate** drops them. - AGENTS / `code-design`: do not read `process.platform` inside library/utility functions. Drive-letter *shape* recognition is platform-independent string matching (no `process.platform`); the platform-dependent *absoluteness* decision is delegated to the `PathNormalizer` from [#510]. ## Design Overview ### Two separate questions, two separate seams The change relies on a clean separation of two distinct questions: 1. **"Is this token shaped like a path worth gating?"** — platform-independent, owned by the classifiers (this issue). A drive-letter shape (`:` followed by `/` or `\`) is recognized unconditionally on every platform. On POSIX this is harmless: the token resolves as a real in-CWD relative path and is gated by the `path` surface as today. 2. **"Is this path absolute (resolve base-independently) or relative (resolve against the effective `cd` base)?"** — platform-dependent, delegated to the `PathNormalizer` ([#510]). Conflating them into a shared helper used by both classifier and projection would be the wrong abstraction — shape recognition is inclusive and platform-independent, absoluteness is exclusive and platform-dependent. [#510] established seam 2 (the `PathNormalizer`); #508 adds the missing case to seam 1 (the classifier) and converts the one straggler in seam 2 — `isRelativeCandidate` — onto the normalizer. ### Shape recognition (classifiers) A private module constant in `token-classification.ts`: ```typescript /** Windows drive-letter absolute path: a drive letter, a colon, then a separator. */ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/; ``` The pattern requires a **separator** after `:`, so it matches the absolute forms (`C:/…`, `C:\…`) but not drive-relative `C:foo` (which `node:path` also treats as *not* absolute — correct to leave as an ordinary token). The single-letter restriction means multi-letter schemes (`https:`, `mailto:`) never match; a single-letter scheme with `//` (`c://x`) is already rejected by `URL_PATTERN` earlier in the prelude. Both classifiers gain a branch returning the token when the pattern matches: - `classifyTokenAsPathCandidate` — new acceptance branch (the core `external_directory` fix; previously dropped both forms). - `classifyTokenAsRuleCandidate` — new acceptance branch (covers the backslash form `D:\…`; the forward-slash form already matched via `token.includes("/")`, but the explicit branch makes both forms first-class and order-independent of the `/` check). ### Routing on the [#510] seam Once the classifier admits a drive token, the `PathNormalizer` routes it (after #508 converts `isRelativeCandidate` to `!this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~")`): | Token | Host flavor | base | Branch | Outcome | | ---------------------------- | ----------- | ---------- | -------- | ---------------------------------------------------- | | `C:/Windows/win.ini` | win32 | known(cwd) | resolved | `C:\Windows\win.ini`, outside CWD → flagged | | `D:\secrets\password.txt` | win32 | known(cwd) | resolved | absolute, outside CWD → flagged | | `C:/projects/app/inside.txt` | win32 | unknown | resolved | inside CWD → not flagged (no over-flag) | | `C:/Windows/win.ini` | posix | known(cwd) | resolved | `/C:/Windows/win.ini`, inside CWD → not flagged | | `cat C:/foo` (path surface) | posix | any | — | real `./C:/foo`, gated by `path` surface as today | Because #508 converts `isRelativeCandidate` to the normalizer's `isAbsolute` (not the hand-rolled `startsWith`), the unknown-base over-flag a classifier-only fix would introduce — a Windows-absolute drive path inside CWD wrongly taking the relative/unknown branch — does not occur: on win32 the path is `isAbsolute`, so it takes the resolved branch with its inside-CWD check. Note #510's `foldCd` absoluteness is already correct, but the projection's relative/unknown decision still runs through the hand-rolled `isRelativeCandidate`, so this conversion is load-bearing for #508, not cosmetic. ## Module-Level Changes - `src/access-intent/bash/token-classification.ts` - Add private `WINDOWS_DRIVE_PATH_PATTERN` constant. - Add a drive-letter acceptance branch to `classifyTokenAsPathCandidate`. - Add a drive-letter acceptance branch to `classifyTokenAsRuleCandidate`. - Update the module/JSDoc summaries that enumerate accepted shapes to include the Windows drive-letter form. - `src/access-intent/bash/bash-path-resolver.ts` - Convert the module-level `isRelativeCandidate` free function to a private `BashPathResolver` method: `private isRelativeCandidate(candidate: string): boolean { return !this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~"); }`. - Update its two call sites (`projectExternalPaths`, `buildRuleCandidatePath`) to `this.isRelativeCandidate(...)`. - The tilde check stays (tilde expansion is a shell concern, not a `node:path` one). - `test/access-intent/bash/token-classification.test.ts` - Add drive-letter acceptance cases for both classifiers (both separators, lowercase drive), plus negative cases pinning that `URL_PATTERN` still rejects `c://x` and that drive-relative `C:foo` (no separator) is not accepted by the strict classifier. - `test/bash-external-directory.test.ts` (a win32 `describe` block alongside the existing POSIX suite) - Add Windows assertions built with `new PathNormalizer("win32", cwd)` passed to `extractExternalPathsFromBashCommand(command, normalizer)` (which calls `BashProgram.parse(command, normalizer)`) — no `vi.mock("node:path")`. - Assert: a Windows-absolute drive path outside cwd is flagged (`cat C:/Windows/win.ini`, `cat D:\secrets\password.txt`); an inside-cwd drive path is not flagged under a known base (`cat C:/projects/app/inside.txt`, cwd `C:\projects\app`) and under an unknown base (`cd "$D" && cat C:/projects/app/inside.txt` — the over-flag the `isRelativeCandidate` conversion prevents). - The existing POSIX suite (built with `new PathNormalizer(process.platform, cwd)`) stays green, proving POSIX behavior unchanged and the `isRelativeCandidate` conversion neutral. - Documentation (descriptive, release-excluded): - `packages/pi-permission-system/docs/architecture/architecture.md` — update the `token-classification.ts` tree entry's classifier shape lists (`strict: /, ~/, ..` and the broad list) to include the Windows drive-letter form. - `.pi/skills/package-pi-permission-system/SKILL.md` — update the "Notes for Agents" line that enumerates the strict classifier's accepted shapes ("absolute, `~/`-relative, or `..`-traversal paths") to include Windows drive-letter paths. A grep of `src/`, `test/`, the architecture doc, and the package SKILL confirms these are the only live references to the classifier's accepted-shape prose; the `docs/plans/*` and `docs/retro/*` mentions are historical records and are left unchanged. ## Test Impact Analysis 1. **New tests enabled.** Platform-independent classifier unit tests for the drive-letter shapes (both separators) in `token-classification.test.ts`. An end-to-end drive-letter assertion built with `new PathNormalizer("win32", cwd)` — exercising the real classifier + projection on Windows semantics on a POSIX CI **without** module mocking. 2. **Redundant tests.** None. The classifier change is additive (no existing acceptance/rejection assertion changes), so every existing assertion in `token-classification.test.ts` and the projection/external-directory suites stays valid. 3. **Tests that must stay as-is.** The POSIX external-directory / projection suite is the regression guard that POSIX gating is unchanged; it must stay green untouched. The `rejectNonPathToken` shared-rejection tests (URL, `@scope`, regex-metachar, bare-slash) pin that drive recognition does not weaken the prelude. ## Invariants at risk The fix touches `token-classification.ts` (Refs [#289] clone-elimination of the shared prelude). The projection invariants it could interact with are owned and re-pinned by [#510]: - **[#289]** — both classifiers delegate the shared rejection cases to `rejectNonPathToken`. Pinned by the `shared rejection: rejectNonPathToken` describe blocks (tested via both classifiers). The new drive branches are added to the **acceptance** gate after the prelude, so the prelude is untouched; the URL / bare-slash negative cases stay green. - **[#393]** — a relative candidate under an unknown `cd` base stays literal-only. Preserved across the `isRelativeCandidate` conversion: a genuinely-relative candidate is still "relative" on both flavors (`isAbsolute` is `false` for `../x`, `src/x`, and for `C:/x` on posix), so it still takes the literal-only branch; only a (base-independent) Windows-absolute drive path on win32 is routed to resolution, which is correct. Pinned by the unknown-base projection tests. - **[#418]** — the boundary decision and dedup use the canonical form while the returned value is the lexical form. Unchanged: #508 adds candidates upstream of this logic. ## TDD Order Prerequisite: [#510] has landed (the `PathNormalizer` seam). It deferred the `isRelativeCandidate` conversion, so step 1 folds that one change in. 1. `fix:` Recognize Windows drive-letter paths and route them on the normalizer. Red: add drive-letter acceptance tests to `token-classification.test.ts` (both classifiers, both separators, lowercase drive, plus negative `c://x` URL and drive-relative `C:foo` cases) and the win32 end-to-end assertions in `test/bash-external-directory.test.ts` built with `new PathNormalizer("win32", cwd)` (outside-cwd `C:/…` and `D:\…` flagged; inside-cwd drive path under known and unknown base not flagged). Green: add `WINDOWS_DRIVE_PATH_PATTERN` and the acceptance branch to both classifiers (update their shape-listing JSDoc); convert `isRelativeCandidate` to a private method delegating to `this.normalizer.isAbsolute` and update its two call sites. Run `pnpm run check` and the full package suite (the conversion must leave the POSIX suite green). Commit: `fix(pi-permission-system): gate Windows drive-letter paths in bash external_directory (#508)`. 2. `docs:` Update descriptive docs for the new accepted shape. Update the `architecture.md` `token-classification.ts` tree entry and the `SKILL.md` "Notes for Agents" classifier-shape line to include Windows drive-letter paths. Commit: `docs(pi-permission-system): note Windows drive-letter paths in bash classifier docs (#508)`. ## Risks and Mitigations - **The `isRelativeCandidate` conversion silently changes POSIX behavior.** Mitigation: it is behavior-neutral on POSIX for every currently-admitted token (`isAbsolute` matches `startsWith("/")` there); the full POSIX `bash-external-directory.test.ts` suite is the regression proof and must stay green in the same commit. - **A second hand-rolled absoluteness check is missed.** Mitigation: grep `bash-path-resolver.ts` for `startsWith("/")` / `startsWith("~")` and confirm `isRelativeCandidate` is the only path-absoluteness straggler; `foldCd` already uses `this.normalizer.isAbsolute`. - **Drive recognition weakens the rejection prelude.** Mitigation: the drive branches are added only to the acceptance gate, after `rejectNonPathToken`; the existing URL / `@scope` / regex-metachar / bare-slash rejection tests stay as guards. ## Open Questions None blocking. The scope (both classifiers, drive-letter-prefix detection, route via the [#510] `PathNormalizer`, fold in the deferred `isRelativeCandidate` conversion, keep POSIX gating) is confirmed. [#510] has landed, so there is no remaining external dependency. [#289]: https://github.com/gotgenes/pi-packages/issues/289 [#393]: https://github.com/gotgenes/pi-packages/issues/393 [#418]: https://github.com/gotgenes/pi-packages/issues/418 [#476]: https://github.com/gotgenes/pi-packages/issues/476 [#509]: https://github.com/gotgenes/pi-packages/issues/509 [#510]: https://github.com/gotgenes/pi-packages/issues/510