19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 520 | 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 apath-surface rule candidate, so it is gated the same as its forward-slash equivalent (dir/file) and the same as the file accessed through thereadtool. - Keep the platform-sensitive backslash decision inside
PathNormalizer(the single home of platform semantics per the package skill), never re-readingprocess.platformin the bash classifier. - Preserve POSIX behavior exactly:
dir\filestays 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_directoryclassifier (classifyTokenAsPathCandidate) is unchanged. Its forward-slash equivalentdir/fileis already dropped by the strict gate (it accepts only absolute,~/,.., and drive-letter shapes), so the backslash formdir\filemust be dropped there too for parity — a backslash traversal (..\secret) is already caught by the sharedincludes("..")branch in both classifiers, so noexternal_directorychange is needed. - The rule-driven promotion classifier
classifyPromotedRuleCandidate(#509) is unchanged. On Windows a backslash token is now shape-recognized byclassifyTokenAsRuleCandidateand never reaches the promoted fallback; on POSIX it stays bare and is subject to #509 promotion only if it matches a specificpathrule — 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.forBashTokenwin32 (plain) resolution.
Background
Relevant modules and their current relationships:
src/access-intent/bash/token-classification.ts— pure, synchronous classifiers.classifyTokenAsRuleCandidate(token)is the broadpath-rule shape gate; it shares the privaterejectNonPathTokenprelude (flags, env assignments, URLs,@scopepackages, bare-slash, regex metachars) with the strictclassifyTokenAsPathCandidateand the promotedclassifyPromotedRuleCandidate(#509). Shape recognition here is platform-independent string matching today; the drive-letter branch (WINDOWS_DRIVE_PATH_PATTERN) is applied unconditionally because on POSIXC:/fooresolves as a real in-CWD relative path andPathNormalizer.isAbsolutedecides routing — but a backslash separator cannot be recognized unconditionally, because on POSIXdir\fileis a single legal filename.src/access-intent/bash/bash-path-resolver.ts—BashPathResolverwalks the AST once, tags each token with its cd-folded effective base, and projects two slices.projectRuleCandidatescallsclassifyTokenAsRuleCandidate(token) ?? classifyPromotedRuleCandidate(token, this.isPromotablePathToken), then resolves the survivor viabuildRuleCandidatePath→normalizer.forBashToken. It already delegates platform-aware string questions to its injectedPathNormalizer(e.g.isRelativeCandidatecallsthis.normalizer.isAbsolute).src/path-normalizer.ts—PathNormalizerholds the hostplatform+ sessioncwdand answers every platform-dependent question (isAbsolute,forBashToken,interpretBashCdTarget, containment). Consumers ask it semantic questions rather than readingprocess.platform; the genericgetPlatform()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 awindowsSeparatorsboolean option (rewrites/→\in the expanded pattern) used bypathMatchOptions; 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.
- Which separator shapes count (shape) —
classifyTokenAsRuleCandidategains an optional{ windowsSeparators?: boolean }option. WhenwindowsSeparatorsis true, a token containing\is accepted as path-shaped, exactly asincludes("/")accepts a forward-slash token. - Whether backslash is a separator (platform) —
PathNormalizeranswers via a new narrowusesWindowsSeparators()accessor (this.platform === "win32").BashPathResolver.projectRuleCandidatesderives the option from the normalizer and passes it, so the platform bit has a single home and the classifier never readsprocess.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
// 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
// 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
// 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 intoplatform. - Resolver → classifier:
classifyTokenAsRuleCandidate(token, { windowsSeparators })— a pure call; the classifier learns one bit, never the platform or the normalizer. - The
#393unknown-base rule (a token after a non-literalcdstays literal-only) and the#418canonical/lexical alias matching both apply to a recognized backslash token unchanged, since it feeds the samebuildRuleCandidatePath.
Module-Level Changes
src/access-intent/bash/token-classification.ts— add theRuleCandidateOptionsinterface and the optionaloptionsparameter with thewindowsSeparators-gated backslash branch onclassifyTokenAsRuleCandidate; update the module header and theclassifyTokenAsRuleCandidatedoc comment to describe the win32 backslash-separator shape.src/path-normalizer.ts— add theusesWindowsSeparators(): booleanaccessor.src/access-intent/bash/bash-path-resolver.ts— inprojectRuleCandidates, derivewindowsSeparatorsfromthis.normalizer.usesWindowsSeparators()and pass it toclassifyTokenAsRuleCandidate; refresh theprojectRuleCandidatesdoc comment to note the win32 backslash-separator recognition.- Docs:
packages/pi-permission-system/docs/architecture/architecture.md— update thetoken-classification.tsline (755) to name the win32 backslash-separator shape and thewindowsSeparatorsoption onclassifyTokenAsRuleCandidate; update thepath-normalizer.tsline (743) to listusesWindowsSeparators; add the win32 backslash recognition to thebash-path-resolver.tsline (753)projectRuleCandidatesnote. 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 apath-surface candidate (gated the same asdir/file), decided byPathNormalizer.usesWindowsSeparators(), while on POSIXdir\filestays bare. Add a matching bullet to the "Windows and Git Bash" section (the drive-letter/case-fold facts) noting the backslash-relativepath-surface recognition.packages/pi-permission-system/docs/configuration.md— extend thepath-surface note (around line 363) to add that on Windows a backslash-relative bash argument (cat dir\file) is gated by apathrule 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
- New tests enabled by this change:
classifyTokenAsRuleCandidate(pure):dir\filewith{ windowsSeparators: true }→ returned; the same token with no option (and with{ windowsSeparators: false }) →null; a backslash regex-metachar token (a\|b) →nulleven under the flag (the reject prelude still fires); a backslash traversal (..\x) → returned regardless (already viaincludes("..")).PathNormalizer.usesWindowsSeparators():truefor an injectedwin32normalizer,falseforposix/linux.BashProgram.parse/BashPathResolver: with awin32normalizer,cat dir\fileyields a rule candidate whosematchValues()equal those ofcat dir/file(parity); with aposixnormalizer,cat dir\fileyields no rule candidate (POSIX guard).
- Redundant tests: none.
The existing
classifyTokenAsRuleCandidatetests assert the current shape acceptances with no option and stay valid — the backslash recognition is an additive, flag-gated branch. - Tests that must stay as-is: the existing
token-classification,program(including the win32-projection describe block), andbash-pathgate tests exercising the#393/#418/#533invariants — 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\filestays a bare token on POSIX and is not treated as a path. Pinned by a newBashProgram.parsetest with aposixnormalizer 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-optiondir\file→nullcase. #533MSYS 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 unchangedforBashToken), and covered by the existing win32-projection tests inprogram.test.ts.#418canonical/lexical alias parity — a recognized backslash token resolves through the sameforBashToken/matchValuespath asdir/file. Pinned by the new parity assertion (dir\filematchValues equaldir/filematchValues 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.
-
Classifier backslash branch (pure). Test
classifyTokenAsRuleCandidate:dir\fileaccepted 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 theRuleCandidateOptionsinterface and the optionaloptionsparameter with thewindowsSeparators-gated branch; update the module/function doc comments. Commit:feat(pi-permission-system): recognize win32 backslash-relative path tokens. -
Normalizer accessor + resolver wiring. Test
PathNormalizer.usesWindowsSeparators()(win32→ true,posix→ false) and, viaBashProgram.parse(win32-projection describe block), thatcat dir\fileyields a rule candidate whosematchValues()equalcat dir/file's, while aposixnormalizer yields no candidate. AddusesWindowsSeparators()toPathNormalizerand wire it intoprojectRuleCandidates; refresh the resolver doc comment. (The accessor lands with its sole consumer, sopnpm fallow dead-codestays clean.) Commit:feat(pi-permission-system): gate win32 backslash-relative bash args via path rules. -
End-to-end bash-path gate repro. Test in
bash-path.test.ts(injecting awin32PathNormalizer) that with apathrule"dir/file": "deny", a bashcat dir\fileresolves to deny (the issue's win32 repro), while the same command on aposixnormalizer is unaffected. Commit:test(pi-permission-system): cover win32 backslash-relative path gating end to end. -
Docs. Update
architecture.md, the packageSKILL.md, andconfiguration.mdper 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 sharedrejectNonPathTokenprelude (which already refuses the common regex-metachar shapes\|,\(,\)) and by the fail-safe direction: an unintended recognition can only add a prompt against a matchingpathrule, 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
forBashTokenandpathMatchOptionsfold, and the parity test assertsdir\fileanddir/fileproduce identicalmatchValues()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 aposix-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.