feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,221 @@
---
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