---
issue: 520
issue_title: "Bash backslash-relative arguments (dir\\file) bypass the path permission surface on Windows"
---

# Bash backslash-relative arguments — win32 path-surface shape recognition

## Release Recommendation

**Release:** ship independently

This issue is a standalone Windows bug fix.
It is not a member of any architecture-roadmap release batch — the Phase 9 spine listing explicitly records [#520] under "Open issues swept and out of scope" (win32 backslash-relative bug), and every numbered roadmap step is tagged `Release: independent`.
So it ships on its own once landed.

## Problem Statement

A `path` permission rule gates a file when it is accessed through the `read` tool or through a bash command that uses a forward-slash relative path (`cat dir/file`), but not when the same file is referenced with a Windows backslash separator (`cat dir\file`) on Windows.
The broad bash classifier `classifyTokenAsRuleCandidate` (`src/access-intent/bash/token-classification.ts`) feeds the `path` surface for bash and accepts a token only if it starts with `.`, contains `/`, contains `..`, or is a Windows drive-letter absolute path (`C:/…` / `C:\…`).
A backslash-relative token like `dir\file` has none of these shapes, so it is dropped before rule evaluation and bypasses the `path` surface on Windows.

This is a shape-recognition gap, distinct from the [#509] bare-filename work: [#509] promotes a truly bare token (`id_rsa`) by matching an active `path` rule, whereas this is about recognizing the backslash separator as a relative-path marker on Windows.
It is platform-specific: on Windows `\` is a path separator, but on POSIX `\` is a legal filename character, so `dir\file` must remain a bare token on POSIX.

## Goals

- On Windows, recognize a backslash-relative bash token (`dir\file`, no `/`, no leading `.`, no `..`, not a drive-letter absolute) as a `path`-surface rule candidate, so it is gated the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool.
- Keep the platform-sensitive backslash decision inside `PathNormalizer` (the single home of platform semantics per the package skill), never re-reading `process.platform` in the bash classifier.
- Preserve POSIX behavior exactly: `dir\file` stays a bare token on POSIX (backslash is a legal filename character there) and is not treated as a path.

This is not a breaking change.
It only tightens gating on Windows for backslash-relative tokens that match an existing `path` rule; it never loosens an existing decision, and no config field, default, or output shape changes.

## Non-Goals

- The strict `external_directory` classifier (`classifyTokenAsPathCandidate`) is unchanged.
  Its forward-slash equivalent `dir/file` is already dropped by the strict gate (it accepts only absolute, `~/`, `..`, and drive-letter shapes), so the backslash form `dir\file` must be dropped there too for parity — a backslash *traversal* (`..\secret`) is already caught by the shared `includes("..")` branch in both classifiers, so no `external_directory` change is needed.
- The rule-driven promotion classifier `classifyPromotedRuleCandidate` ([#509]) is unchanged.
  On Windows a backslash token is now shape-recognized by `classifyTokenAsRuleCandidate` and never reaches the promoted fallback; on POSIX it stays bare and is subject to [#509] promotion only if it matches a specific `path` rule — existing behavior, untouched.
- Argument-position / per-command awareness (knowing that a token is a subcommand or search pattern rather than a file) — the same fail-safe scope [#509] set: it prompts, never silently allows.
- MSYS/Git Bash POSIX-absolute interpretation ([#533]) is untouched; this change only widens the relative-shape gate, and a recognized backslash token flows through the existing `PathNormalizer.forBashToken` win32 (`plain`) resolution.

## Background

Relevant modules and their current relationships:

- `src/access-intent/bash/token-classification.ts` — pure, synchronous classifiers.
  `classifyTokenAsRuleCandidate(token)` is the broad `path`-rule shape gate; it shares the private `rejectNonPathToken` prelude (flags, env assignments, URLs, `@scope` packages, bare-slash, regex metachars) with the strict `classifyTokenAsPathCandidate` and the promoted `classifyPromotedRuleCandidate` ([#509]).
  Shape recognition here is platform-independent string matching today; the drive-letter branch (`WINDOWS_DRIVE_PATH_PATTERN`) is applied unconditionally because on POSIX `C:/foo` resolves as a real in-CWD relative path and `PathNormalizer.isAbsolute` decides routing — but a backslash separator cannot be recognized unconditionally, because on POSIX `dir\file` is a single legal filename.
- `src/access-intent/bash/bash-path-resolver.ts` — `BashPathResolver` walks the AST once, tags each token with its cd-folded effective base, and projects two slices.
  `projectRuleCandidates` calls `classifyTokenAsRuleCandidate(token) ?? classifyPromotedRuleCandidate(token, this.isPromotablePathToken)`, then resolves the survivor via `buildRuleCandidatePath` → `normalizer.forBashToken`.
  It already delegates platform-aware string questions to its injected `PathNormalizer` (e.g. `isRelativeCandidate` calls `this.normalizer.isAbsolute`).
- `src/path-normalizer.ts` — `PathNormalizer` holds the host `platform` + session `cwd` and answers every platform-dependent question (`isAbsolute`, `forBashToken`, `interpretBashCdTarget`, containment).
  Consumers ask it semantic questions rather than reading `process.platform`; the generic `getPlatform()` accessor was retired ([#511], [#513]) so callers do not re-derive platform logic.
- `src/access-intent/bash/program.ts` — `BashProgram.parse(command, normalizer, isPromotable?)` constructs the resolver and eagerly resolves the slices.
- `src/wildcard-matcher.ts` / `src/rule.ts` — already carry a `windowsSeparators` boolean option (rewrites `/` → `\` in the expanded pattern) used by `pathMatchOptions`; this establishes the naming convention this plan reuses for the classifier option.

Constraint from AGENTS.md / the package skill: do not read `process.platform` inside `src/` — an ESLint `no-restricted-syntax` guard blocks it, and platform lives only in `PathNormalizer`.
So the backslash-as-separator decision must be answered by the normalizer, not re-derived in the classifier.

## Design Overview

### Decision model

The classifier stays the single home of path-shape recognition, but the one platform-sensitive shape — "is a backslash a path separator here?"
— is decided by `PathNormalizer` and passed in as a small option, mirroring how `wildcard-matcher.ts` / `rule.ts` already thread a `windowsSeparators` boolean.

1. **Which separator shapes count** (shape) — `classifyTokenAsRuleCandidate` gains an optional `{ windowsSeparators?: boolean }` option.
   When `windowsSeparators` is true, a token containing `\` is accepted as path-shaped, exactly as `includes("/")` accepts a forward-slash token.
2. **Whether backslash is a separator** (platform) — `PathNormalizer` answers via a new narrow `usesWindowsSeparators()` accessor (`this.platform === "win32"`).
   `BashPathResolver.projectRuleCandidates` derives the option from the normalizer and passes it, so the platform bit has a single home and the classifier never reads `process.platform`.

The new accessor is a specific semantic predicate (like `isAbsolute`), not a revival of the retired generic `getPlatform()` — it answers one bounded question the classifier needs, and the caller does not branch on a raw platform value to re-implement path logic.

### Classifier: the backslash branch

```typescript
// token-classification.ts
export interface RuleCandidateOptions {
  /** On win32, a backslash is a path separator, so `dir\file` is path-shaped. */
  readonly windowsSeparators?: boolean;
}

export function classifyTokenAsRuleCandidate(
  token: string,
  options?: RuleCandidateOptions,
): string | null {
  if (rejectNonPathToken(token)) return null;

  if (token.startsWith(".")) return token;
  if (token.includes("/")) return token;
  if (token.includes("..")) return token;
  if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
  if (options?.windowsSeparators && token.includes("\\")) return token;

  return null;
}
```

The shared `rejectNonPathToken` prelude runs first, so a flag, env assignment, URL, `@scope`, or regex-metachar token (`a\|b`, `\(group\)`) is still refused even under the flag — only a plain backslash-relative token survives.
The default (no option) is the exact current behavior, so the other callers and every existing test are unaffected.

### Normalizer: the narrow accessor

```typescript
// PathNormalizer
/** True when the host platform treats a backslash as a path separator (win32). */
usesWindowsSeparators(): boolean {
  return this.platform === "win32";
}
```

### Resolver: derive the option from the normalizer

```typescript
// BashPathResolver.projectRuleCandidates
const windowsSeparators = this.normalizer.usesWindowsSeparators();
for (const { token, base } of candidates) {
  const candidate =
    classifyTokenAsRuleCandidate(token, { windowsSeparators }) ??
    classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
  if (!candidate) continue;
  // unchanged: buildRuleCandidatePath(candidate, base), dedup, push
}
```

A recognized backslash token then flows through the unchanged `buildRuleCandidatePath` → `normalizer.forBashToken("dir\\file", { resolveBase })`.
On win32, `classifyWin32BashToken("dir\\file")` returns `plain` (not a device, drive-mount, or POSIX-absolute), so `forBashToken` delegates to ordinary win32 `forPath`, resolving `<cwd>\dir\file` with the same canonical/lexical `matchValues()` the forward-slash token `dir/file` produces.
`describeBashPathGate` then resolves it against the `path` surface: because `pathMatchOptions` folds a rule's `/` → `\` on win32, a natural `"dir/file": "deny"` (or `"dir\\file": "deny"`) rule matches the token — closing the bypass with no gate-layer change.

### Call-site verification (Law of Demeter / Tell-Don't-Ask)

- Resolver → normalizer: `this.normalizer.usesWindowsSeparators()` — one call, a bounded boolean; no reach-through into `platform`.
- Resolver → classifier: `classifyTokenAsRuleCandidate(token, { windowsSeparators })` — a pure call; the classifier learns one bit, never the platform or the normalizer.
- The `#393` unknown-base rule (a token after a non-literal `cd` stays literal-only) and the `#418` canonical/lexical alias matching both apply to a recognized backslash token unchanged, since it feeds the same `buildRuleCandidatePath`.

## Module-Level Changes

- `src/access-intent/bash/token-classification.ts` — add the `RuleCandidateOptions` interface and the optional `options` parameter with the `windowsSeparators`-gated backslash branch on `classifyTokenAsRuleCandidate`; update the module header and the `classifyTokenAsRuleCandidate` doc comment to describe the win32 backslash-separator shape.
- `src/path-normalizer.ts` — add the `usesWindowsSeparators(): boolean` accessor.
- `src/access-intent/bash/bash-path-resolver.ts` — in `projectRuleCandidates`, derive `windowsSeparators` from `this.normalizer.usesWindowsSeparators()` and pass it to `classifyTokenAsRuleCandidate`; refresh the `projectRuleCandidates` doc comment to note the win32 backslash-separator recognition.
- Docs:
  - `packages/pi-permission-system/docs/architecture/architecture.md` — update the `token-classification.ts` line (755) to name the win32 backslash-separator shape and the `windowsSeparators` option on `classifyTokenAsRuleCandidate`; update the `path-normalizer.ts` line (743) to list `usesWindowsSeparators`; add the win32 backslash recognition to the `bash-path-resolver.ts` line (753) `projectRuleCandidates` note.
    Leave the Phase 9 "swept and out of scope" listing (line 868) intact — it is a historical scope record for that phase.
  - `.pi/skills/package-pi-permission-system/SKILL.md` — the "Notes for Agents" bash-classifier paragraph states the accepted shapes and that "The broader classifier also recognizes the backslash drive form (`D:\…`)"; add that on win32 a backslash-relative token (`dir\file`) is also recognized as a `path`-surface candidate (gated the same as `dir/file`), decided by `PathNormalizer.usesWindowsSeparators()`, while on POSIX `dir\file` stays bare.
    Add a matching bullet to the "Windows and Git Bash" section (the drive-letter/case-fold facts) noting the backslash-relative `path`-surface recognition.
  - `packages/pi-permission-system/docs/configuration.md` — extend the `path`-surface note (around line 363) to add that on Windows a backslash-relative bash argument (`cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`).

No test-fixture change is required: the new classifier parameter is optional (existing callers and fakes are source-compatible), and `usesWindowsSeparators` lands with its sole consumer (the resolver), so no interface widening breaks any fake and no export is added without a caller.
No file listed here is claimed as unchanged in Non-Goals; the strict classifier, the promoted classifier, and the config schema are genuinely untouched.

## Test Impact Analysis

1. **New tests enabled by this change:**
   - `classifyTokenAsRuleCandidate` (pure): `dir\file` with `{ windowsSeparators: true }` → returned; the same token with no option (and with `{ windowsSeparators: false }`) → `null`; a backslash regex-metachar token (`a\|b`) → `null` even under the flag (the reject prelude still fires); a backslash traversal (`..\x`) → returned regardless (already via `includes("..")`).
   - `PathNormalizer.usesWindowsSeparators()`: `true` for an injected `win32` normalizer, `false` for `posix`/`linux`.
   - `BashProgram.parse` / `BashPathResolver`: with a `win32` normalizer, `cat dir\file` yields a rule candidate whose `matchValues()` equal those of `cat dir/file` (parity); with a `posix` normalizer, `cat dir\file` yields no rule candidate (POSIX guard).
2. **Redundant tests:** none.
   The existing `classifyTokenAsRuleCandidate` tests assert the current shape acceptances with no option and stay valid — the backslash recognition is an additive, flag-gated branch.
3. **Tests that must stay as-is:** the existing `token-classification`, `program` (including the win32-projection describe block), and `bash-path` gate tests exercising the `#393`/`#418`/`#533` invariants — they pin the unchanged resolution path.

## Invariants at risk

This change touches `token-classification.ts` (extracted [#475], drive-letter branch [#508]), `bash-path-resolver.ts` (cd-projection [#475], canonical matching [#418], `#393` unknown-base rule), and `path-normalizer.ts` (platform seam [#510], [#533]).
The invariants that must not regress, and their pins:

- **POSIX behavior is preserved** — `dir\file` stays a bare token on POSIX and is not treated as a path.
  Pinned by a new `BashProgram.parse` test with a `posix` normalizer asserting no rule candidate, plus the existing default-platform resolver tests.
- **Default classifier behavior is unchanged** — `classifyTokenAsRuleCandidate(token)` with no option matches every current result.
  Pinned by the existing token-classification suite (all no-option calls) plus a new explicit no-option `dir\file` → `null` case.
- **`#533` MSYS interpretation is untouched** — a win32 POSIX-absolute (`/tmp/foo`) still resolves literal-only; a drive-mount (`/c/x`) still translates.
  Preserved structurally (the backslash branch only widens the *relative* shape gate; recognized tokens use the unchanged `forBashToken`), and covered by the existing win32-projection tests in `program.test.ts`.
- **`#418` canonical/lexical alias parity** — a recognized backslash token resolves through the same `forBashToken`/`matchValues` path as `dir/file`.
  Pinned by the new parity assertion (`dir\file` matchValues equal `dir/file` matchValues under a win32 normalizer).

## TDD Order

Numbered red→green→commit cycles.
The classifier parameter is optional and the normalizer accessor lands with its consumer, so no step breaks a fake at the type level.

1. **Classifier backslash branch (pure).**
   Test `classifyTokenAsRuleCandidate`: `dir\file` accepted under `{ windowsSeparators: true }`, rejected with no option / `{ windowsSeparators: false }`, still rejected for a backslash regex-metachar token under the flag, and a backslash traversal accepted regardless.
   Add the `RuleCandidateOptions` interface and the optional `options` parameter with the `windowsSeparators`-gated branch; update the module/function doc comments.
   Commit: `feat(pi-permission-system): recognize win32 backslash-relative path tokens`.

2. **Normalizer accessor + resolver wiring.**
   Test `PathNormalizer.usesWindowsSeparators()` (`win32` → true, `posix` → false) and, via `BashProgram.parse` (win32-projection describe block), that `cat dir\file` yields a rule candidate whose `matchValues()` equal `cat dir/file`'s, while a `posix` normalizer yields no candidate.
   Add `usesWindowsSeparators()` to `PathNormalizer` and wire it into `projectRuleCandidates`; refresh the resolver doc comment. (The accessor lands with its sole consumer, so `pnpm fallow dead-code` stays clean.) Commit: `feat(pi-permission-system): gate win32 backslash-relative bash args via path rules`.

3. **End-to-end bash-path gate repro.**
   Test in `bash-path.test.ts` (injecting a `win32` `PathNormalizer`) that with a `path` rule `"dir/file": "deny"`, a bash `cat dir\file` resolves to deny (the issue's win32 repro), while the same command on a `posix` normalizer is unaffected.
   Commit: `test(pi-permission-system): cover win32 backslash-relative path gating end to end`.

4. **Docs.**
   Update `architecture.md`, the package `SKILL.md`, and `configuration.md` per Module-Level Changes.
   Commit: `docs(pi-permission-system): document win32 backslash-relative path recognition`.

## Risks and Mitigations

- **A backslash-containing non-path token on win32 (e.g. a `\d`-style regex fragment)** could be treated as a path candidate under the flag.
  Mitigated by the shared `rejectNonPathToken` prelude (which already refuses the common regex-metachar shapes `\|`, `\(`, `\)`) and by the fail-safe direction: an unintended recognition can only *add* a prompt against a matching `path` rule, never silently allow.
  This mirrors the accepted fail-safe scope of [#509].
- **Windows fold divergence** — the classifier recognizing a token the later path-surface match would not gate.
  Mitigated because a recognized backslash token resolves through the unchanged `forBashToken` and `pathMatchOptions` fold, and the parity test asserts `dir\file` and `dir/file` produce identical `matchValues()` under a win32 normalizer.
- **POSIX regression** — accidentally recognizing backslash on POSIX.
  Mitigated by gating the branch strictly on the normalizer's `usesWindowsSeparators()` and pinning the POSIX guard with a `posix`-normalizer resolver test.

## Open Questions

None.
The design reuses the established `windowsSeparators` option convention and the `PathNormalizer` platform seam; no follow-up work is deferred.

[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#475]: https://github.com/gotgenes/pi-packages/issues/475
[#508]: https://github.com/gotgenes/pi-packages/issues/508
[#509]: https://github.com/gotgenes/pi-packages/issues/509
[#510]: https://github.com/gotgenes/pi-packages/issues/510
[#511]: https://github.com/gotgenes/pi-packages/issues/511
[#513]: https://github.com/gotgenes/pi-packages/issues/513
[#520]: https://github.com/gotgenes/pi-packages/issues/520
[#533]: https://github.com/gotgenes/pi-packages/issues/533
