mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
222 lines
19 KiB
Markdown
222 lines
19 KiB
Markdown
---
|
|
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
|