21 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 653 | Windows: path rule "/dev/null": "allow" never matches due to wildcard separator normalization asymmetry |
Symmetric win32 separator fold in path-rule matching
Release Recommendation
Release: ship independently
This issue is not part of any numbered roadmap step in docs/architecture/architecture.md, so no release batch applies.
It is a user-reported Windows bug with a self-contained fix; a fix: commit cuts a release on the next release-please merge.
Problem Statement
On a win32 host the path-surface wildcard matcher folds separators in one direction only.
compileWildcardPattern rewrites / to \ in the rule pattern, but wildcardMatch tests the value exactly as it arrives.
Every match value that still carries forward slashes is therefore unmatchable by any rule.
The reporter's case is the Git Bash device token: under path: { "*": "ask", "/dev/null": "allow" } on Windows, echo hi > /dev/null still prompts, because the rule compiles to ^\dev\null$ while the value stays /dev/null.
Reproduced during planning against the real pipeline:
BashProgram.parse("echo hi > /dev/null", win32 normalizer)
pathRuleCandidates: [{ token: "/dev/null", matchValues: ["/dev/null"] }]
manager.check(path, values) with ["*": ask, "/dev/null": allow]
→ { state: "ask", matchedPattern: "*" }
The device token is not the only casualty — it is the shape with no workaround.
AccessPath match values on win32 mix separator conventions: the absolute and cwd-relative aliases come out of win32.resolve / win32.relative with backslashes, while the as-typed literal alias, forDevice, and forLiteral keep forward slashes.
#533 already hit this once and worked around it by hand-attaching a backslash match alias to the non-mount POSIX absolute literal (/tmp/foo also matches as \tmp\foo), which is the same bug patched at one call site instead of at the fold.
Goals
- Make the win32
windowsSeparatorsfold symmetric: the same separator normalization applies to the rule pattern and to the matched value. - Make
path: { "/dev/null": "allow" }(and any other forward-slash path rule) match on Windows, on both the config and session-approval layers. - Make the fold structurally impossible to half-apply, so this class of bug cannot return through a new call site.
- Remove the #533 backslash match alias, now redundant.
- Correct
docs/configuration.md, which claims the safe device paths "never trigger the gate" when the exclusion only coversexternal_directory.
This is not a breaking change. It affects win32 only, and it makes rules that were silently inert start matching as documented. Deny rules gain reach in the same direction as allow rules, so the change is fail-safe, never a bypass.
Non-Goals
- Exempting safe system devices from the
pathsurface.isSafeSystemPathexempts/dev/nullfromexternal_directoryonly; the cross-cuttingpathgate resolves the token like any other, on POSIX as well. That behavior is correct — an explicitpathrule remains the lever, and a user runningpath: { "*": "deny" }should keep full strictness. The documentation is what over-claims; this plan corrects the prose, not the gate. deriveApprovalPattern's ambientnode:pathread. It readsdirname/sepfrom the host rather than the injectedPathFlavor, so a session approval for/dev/nullon a real Windows host yields the mixed pattern/dev\*. It matches once the fold is symmetric, so it is not a blocker. Filed as #655 with a design sketch for the missing collaborator.- The
caseInsensitivehalf ofWildcardMatchOptions. It is applied through the regexiflag and is already symmetric; it stays as-is. - Non-path surfaces.
pathMatchOptions(src/rule.ts) handsflavor.matchOptionsonly toPATH_SURFACES, sobash, tool-name,mcp, andskillmatching keeps its exact, unfolded semantics. - MSYS mount mapping.
ADR 0003's rejection of
cygpathshell-outs and/tmp→%TEMP%mapping stands untouched; only the alias workaround it introduced goes away.
Background
Relevant modules:
src/wildcard-matcher.ts—compileWildcardPatternexpands~, applies thewindowsSeparatorsrewrite, escapes, and builds the regex;wildcardMatchcompiles a pattern and calls.regex.test(value).CompiledWildcardPattern<TState>exposes the rawregex, andfindCompiledWildcardMatchcallsp.regex.test(name)directly.src/rule.ts—pathMatchOptions(surface, flavor)returnsflavor.matchOptionsforPATH_SURFACESandundefinedotherwise;ruleMatchesis the sole path-rule match site.src/path/path-flavor.ts—win32PathFlavor.matchOptionsis{ caseInsensitive: true, windowsSeparators: true }; the POSIX flavor's isundefined.src/access-intent/access-path.ts—matchValues()is the lexical alias union ∪ canonical;forLiteral(literal, matchAliases?)carries the #533 alias;forDevice(devicePath)preserves an MSYS device verbatim across all three representations.src/path-normalizer.ts—forBashTokendispatches onflavor.bashTokenShape(token):device→AccessPath.forDevice,drive-mount→ translatedforPath,posix-absolute→forLiteralplus a hand-built backslash alias,plain→forPath.
Constraints from AGENTS.md and the package skill that apply:
PathFlavorowns the one win32-vs-POSIX decision; nosrc/module readsprocess.platform(ESLint-guarded). This change adds no platform read — it consumes the already-resolvedmatchOptions.- Wildcard matching must be explicit and tested; silent over-matching is a permission bypass. The fold widens matching symmetrically for allow and deny, and only within the platform's own separator equivalence.
docs/architecture/architecture.mdmodule-tree entries describe current behavior and cite an issue only for an active constraint.
Design Overview
The fold is one relation, applied to two sides
windowsSeparators expresses a single fact: on Windows / and \ are the same separator, so two strings differing only in separators name the same path.
An equivalence relation has to be applied to both operands.
Today it is applied to one, so the matcher answers "different" for two spellings of the same path.
The fix is to normalize both sides through one named helper:
function foldSeparators(value: string, options?: WildcardMatchOptions): string {
return options?.windowsSeparators ? value.replaceAll("/", "\\") : value;
}
compileWildcardPattern applies it to the expanded pattern (where the existing inline replaceAll lives); wildcardMatch applies it to the value before testing.
The missing collaborator: the compiled pattern should own matching
Folding the value inside wildcardMatch fixes the live path, but it leaves the same trap set for the next caller.
compileWildcardPattern bakes half the fold into a regex and then hands out the raw regex, so any consumer that calls .test(value) re-opens the asymmetry — which is exactly what findCompiledWildcardMatch does today (harmlessly, since nothing compiles it with options yet).
So the compiled pattern takes over matching and the raw regex stops being part of the shape:
export interface CompiledWildcardPattern<TState> {
readonly pattern: string;
readonly state: TState;
/** Test a value, applying the same folding the pattern was compiled with. */
matches(value: string): boolean;
}
Call sites become p.matches(name) and compileWildcardPattern(pattern, null, options).matches(value).
Both halves of the fold now live on one object, and there is no API left that can apply one without the other.
This is a small surface: regex has two production readers (both in wildcard-matcher.ts) and four test readers.
The #533 alias becomes redundant
With the fold symmetric, a forward-slash literal is matchable as typed, so forBashToken's posix-absolute branch reduces to the plain literal:
case "posix-absolute": {
// A non-mount POSIX absolute (`/tmp`, `/usr`) has an install-dependent
// Windows target this package cannot know, so it is kept literal.
return this.forLiteral(normalizePathPolicyLiteral(token));
}
PathNormalizer.forLiteral and AccessPath.forLiteral then drop their matchAliases parameter — no caller supplies one.
The matchAliases constructor field stays: forPath still builds matchValues() from the getPathPolicyValues union.
Verified during planning: with the fold in place and the alias removed, the full 2594-test suite passes except the two assertions that spell the alias out, and the #533 end-to-end guarantee (test/permission-manager-unified.test.ts, "a /tmp* allow rule suppresses a Git Bash /tmp path") stays green on the matcher alone.
Rejected alternative: normalize separators when building match values
The other place to close the gap is AccessPath — emit backslash-normalized aliases for every win32 match value at construction.
Rejected: it scatters the fold across the value-construction sites (getPathPolicyValues, forDevice, forLiteral) while the pattern half stays in the matcher, which is how #533's alias came about in the first place.
It also misses wildcardMatch's other consumer, isPiInfrastructureRead, which matches a configured directory glob against a boundary value rather than an AccessPath alias union.
Keeping both halves of the relation in the matcher is the single-home option.
Module-Level Changes
Source:
src/wildcard-matcher.ts— addfoldSeparators; call it fromcompileWildcardPattern(replacing the inlinereplaceAll) and from the newmatchesimplementation. ReplaceregexonCompiledWildcardPattern<TState>withmatches(value: string): boolean. RoutefindCompiledWildcardMatchandwildcardMatchthroughmatches. Update theWildcardMatchOptions.windowsSeparatorsdoc comment — it currently says the rewrite applies "in the expanded pattern".src/path-normalizer.ts—forBashToken'sposix-absolutebranch returnsthis.forLiteral(literal); drop thematchAliasesparameter fromforLiteraland the alias rationale from theforBashTokendoc comment.src/access-intent/access-path.ts— drop thematchAliasesparameter and its doc paragraph fromstatic forLiteral; keep the privatematchAliasesfield (still populated byforPath).
Tests:
test/wildcard-matcher.test.ts— add the symmetric-fold cases; migrate the four.regex.test(...)call sites (lines 189, 190, 214, 417) to.matches(...).test/rule.test.ts— add the win32 path-surface case (forward-slash rule vs forward-slash value) beside the existing "a forward-slash external_directory pattern matches a backslash value" test, plus a negative pinning that thebashsurface stays unfolded.test/permission-manager-unified.test.ts— add the end-to-end/dev/nullrepro; update the #533 test's comment, which currently explains the pass by "the literal carries a backslash match alias".test/path-normalizer.test.ts— "forBashToken keeps a non-mount POSIX absolute as a literal":matchValues()becomes["/tmp/foo"]; update the explanatory comment.test/access-intent/bash/program.test.ts— "keeps a non-mount POSIX absolute as a literal rule candidate": same assertion change.
Docs:
docs/decisions/0003-git-bash-posix-path-semantics.md— rewrite the Consequences bullet that documents the backslash match alias: the matcher now folds both the rule pattern and the match value, so a forward-slash literal is matchable as typed and no alias is carried.docs/architecture/architecture.md— thewildcard-matcher.tsmodule-tree entry gains the active constraint (the win32 fold applies to both pattern and value; matching goes throughCompiledWildcardPattern.matches()so it cannot be half-applied). Theaccess-path.tsentry drops theforLiteral(literal, matchAliases?)signature and its parenthetical about the win32 backslash alias.docs/configuration.md— the Windows-matching paragraph states that the fold applies to the rule pattern and the matched value; the Git Bash device bullet scopes its "never trigger the gate" claim toexternal_directoryand says thepathsurface still governs the device token, which a rule written as typed (path: { "/dev/null": "allow" }) now matches..pi/skills/package-pi-permission-system/SKILL.md— replace the "carries a backslash alias … when adding another literal-only path shape on win32, give it a backslash match alias" guidance with the symmetric-fold rule.
No roadmap step-mark (✅) applies — this issue is not a numbered phase step.
README.md was grepped for /dev/null and separator wording: no hits, no change.
Test Impact Analysis
- Newly enabled tests.
Matcher-level symmetry is now assertable directly (
wildcardMatch("/dev/null", "/dev/null", { windowsSeparators: true })), and the reported repro becomes a manager-level test that runs on POSIX CI viawin32PathFlavor— the whole path fromBashProgram.parsethroughPermissionManager.check, which no existing test covers for the device shape. - Tests that become redundant.
None are removed.
The two alias assertions (
path-normalizer.test.ts,program.test.ts) are updated, not deleted — they still pin that a non-mount POSIX absolute stays literal-only with no canonical. The #533 manager test is deliberately kept: after this change it pins the guarantee at the matcher rather than at the alias, which is the stronger statement. - Tests that must stay as-is.
test/path/path-flavor.test.ts(matchOptionscomposition),test/rule.test.ts's existing win32 case-fold and backslash-value cases, andtest/access-intent/bash/msys-bash-tokens.test.ts(shape classification) all exercise layers this change does not touch.
Invariants at risk
| Invariant | Source | Pinned by |
|---|---|---|
A natural /tmp* allow rule suppresses a Git Bash /tmp path on win32 |
#533, ADR 0003 | test/permission-manager-unified.test.ts — "win32: a /tmp* allow rule suppresses a Git Bash /tmp path" |
A non-mount POSIX absolute is never fabricated into C:\tmp\foo |
#533 | test/path-normalizer.test.ts (value() / boundaryValue()), test/access-intent/bash/program.test.ts |
| Win32 path matching folds case | #382 | test/rule.test.ts, test/permission-manager-unified.test.ts |
The bash surface stays case- and separator-sensitive |
#382 | test/rule.test.ts — "win32: bash surface stays case-sensitive (not a path surface)"; extended here with a separator case |
Safe devices never reach external_directory |
#533 | test/bash-external-directory.test.ts, test/path/path-containment.test.ts |
The first is the one at genuine risk, since step 3 removes the mechanism its comment credits. Planning verified it stays green on the matcher fold alone; the step's verify criterion names it explicitly.
No quantitative invariant (token budget, byte-identical prefix, latency) is involved.
TDD Order
- Symmetric separator fold.
Test surface:
test/wildcard-matcher.test.ts,test/rule.test.ts,test/permission-manager-unified.test.ts. Red — a forward-slash pattern matches a forward-slash value underwindowsSeparators(/dev/null,/dev/*,src/*againstsrc/foo.ts); the fold stays off by default; a win32pathrule/dev/nullmatches the value/dev/nullwhile abashrule does not fold; end to end, a win32 manager with["*": ask, "/dev/null": allow]answersallowfor the match valuesBashProgram.parse("echo hi > /dev/null", winNormalizer)produces, andaskwithout the rule. Green — extractfoldSeparatorsand apply it to the value inwildcardMatchas well as the expanded pattern. Verify: full suite green (confirmed during planning — 2594 tests, no regressions). Commit:fix(pi-permission-system): fold separators on both sides of a win32 path match (#653) - Matching moves onto the compiled pattern.
Test surface:
test/wildcard-matcher.test.ts. Red — compile with{ windowsSeparators: true }and assertcompiled.matches("/dev/null"); the method does not exist. Green — replaceregexwithmatches(value)onCompiledWildcardPattern, routefindCompiledWildcardMatchandwildcardMatchthrough it, and migrate the four.regex.test(...)test call sites in the same commit (the type change breaks them immediately). Verify:pnpm run check— the removedregexfield must have no surviving reader. Commit:refactor(pi-permission-system): let the compiled wildcard pattern own matching (#653) - Drop the redundant backslash match alias.
Test surface:
test/path-normalizer.test.ts,test/access-intent/bash/program.test.ts. Red — change bothmatchValues()assertions to["/tmp/foo"]; they fail while the alias is still attached. Green —forBashToken'sposix-absolutebranch returnsthis.forLiteral(literal); drop thematchAliasesparameter fromPathNormalizer.forLiteralandAccessPath.forLiteralin the same commit (a single call site, so the type checker will not accept them split). Verify: the #533 manager test stays green (confirmed during planning). Commit:refactor(pi-permission-system): drop the win32 literal backslash match alias (#653) - Documentation.
No test surface.
Update ADR 0003's Consequences bullet, the two
architecture.mdmodule-tree entries, the twoconfiguration.mdpassages, and the package skill's alias guidance. Verify:pnpm exec rumdl checkon the edited files, plus a grep formatch alias/backslash aliasacrossdocs/and.pi/skills/to confirm no stale claim survives. Commit:docs(pi-permission-system): record the symmetric win32 separator fold (#653)
Risks and Mitigations
- Broader matching could surprise a Windows user.
A forward-slash rule that was silently inert starts matching.
For
allowthat is the reported fix; fordenyandaskit is strictly more restrictive, so the failure mode is a prompt, never a bypass. Mitigation: the behavior change is win32-only, stated in thefix:commit body, and both directions are covered by tests. - Removing the #533 alias regresses its guarantee. Mitigation: step 3 lands only after step 1, its verify criterion names the #533 manager test, and the combination was spiked during planning (both changes applied, full suite run: only the two alias assertions failed).
- Replacing
regexwithmatchestouches a semi-public shape.CompiledWildcardPatternis not re-exported from the package entry point and has no cross-extension consumer; its only production readers are insidewildcard-matcher.ts. Mitigation:pnpm run checkin step 2, and the migration of all four test readers in the same commit. - The doc correction could read as a behavior regression.
configuration.mdcurrently promises thatecho hi > /dev/nulldoes not prompt. Mitigation: the corrected prose names the lever explicitly — the device is exempt fromexternal_directory, and apathrule written as typed governs it — so a reader hitting a prompt finds the fix in the same paragraph.
Open Questions
- Should safe system devices be exempt from the
pathsurface as well asexternal_directory? Decided out of scope for this issue (documentation corrected instead); revisit only if a user reports that an explicitpathrule is an unreasonable requirement for/dev/null. _compileWildcardPatterns,compileWildcardPatternEntries, andfindCompiledWildcardMatchForNameshave no production callers today — only tests. Not addressed here;pnpm fallow dead-codereports the package clean, so any pruning needs its own justification.- #655 carries the
deriveApprovalPatterndesign question (ambientnode:pathread, and whether the derivation belongs onPathNormalizer,AccessPath, orSessionApproval).