Files
my-pi/pi-permission-system/docs/plans/0653-win32-path-rule-separator-fold.md
T

21 KiB
Raw Blame History

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 windowsSeparators fold 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 covers external_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 path surface. isSafeSystemPath exempts /dev/null from external_directory only; the cross-cutting path gate resolves the token like any other, on POSIX as well. That behavior is correct — an explicit path rule remains the lever, and a user running path: { "*": "deny" } should keep full strictness. The documentation is what over-claims; this plan corrects the prose, not the gate.
  • deriveApprovalPattern's ambient node:path read. It reads dirname/sep from the host rather than the injected PathFlavor, so a session approval for /dev/null on 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 caseInsensitive half of WildcardMatchOptions. It is applied through the regex i flag and is already symmetric; it stays as-is.
  • Non-path surfaces. pathMatchOptions (src/rule.ts) hands flavor.matchOptions only to PATH_SURFACES, so bash, tool-name, mcp, and skill matching keeps its exact, unfolded semantics.
  • MSYS mount mapping. ADR 0003's rejection of cygpath shell-outs and /tmp%TEMP% mapping stands untouched; only the alias workaround it introduced goes away.

Background

Relevant modules:

  • src/wildcard-matcher.tscompileWildcardPattern expands ~, applies the windowsSeparators rewrite, escapes, and builds the regex; wildcardMatch compiles a pattern and calls .regex.test(value). CompiledWildcardPattern<TState> exposes the raw regex, and findCompiledWildcardMatch calls p.regex.test(name) directly.
  • src/rule.tspathMatchOptions(surface, flavor) returns flavor.matchOptions for PATH_SURFACES and undefined otherwise; ruleMatches is the sole path-rule match site.
  • src/path/path-flavor.tswin32PathFlavor.matchOptions is { caseInsensitive: true, windowsSeparators: true }; the POSIX flavor's is undefined.
  • src/access-intent/access-path.tsmatchValues() 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.tsforBashToken dispatches on flavor.bashTokenShape(token): deviceAccessPath.forDevice, drive-mount → translated forPath, posix-absoluteforLiteral plus a hand-built backslash alias, plainforPath.

Constraints from AGENTS.md and the package skill that apply:

  • PathFlavor owns the one win32-vs-POSIX decision; no src/ module reads process.platform (ESLint-guarded). This change adds no platform read — it consumes the already-resolved matchOptions.
  • 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.md module-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:

  1. src/wildcard-matcher.ts — add foldSeparators; call it from compileWildcardPattern (replacing the inline replaceAll) and from the new matches implementation. Replace regex on CompiledWildcardPattern<TState> with matches(value: string): boolean. Route findCompiledWildcardMatch and wildcardMatch through matches. Update the WildcardMatchOptions.windowsSeparators doc comment — it currently says the rewrite applies "in the expanded pattern".
  2. src/path-normalizer.tsforBashToken's posix-absolute branch returns this.forLiteral(literal); drop the matchAliases parameter from forLiteral and the alias rationale from the forBashToken doc comment.
  3. src/access-intent/access-path.ts — drop the matchAliases parameter and its doc paragraph from static forLiteral; keep the private matchAliases field (still populated by forPath).

Tests:

  1. test/wildcard-matcher.test.ts — add the symmetric-fold cases; migrate the four .regex.test(...) call sites (lines 189, 190, 214, 417) to .matches(...).
  2. 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 the bash surface stays unfolded.
  3. test/permission-manager-unified.test.ts — add the end-to-end /dev/null repro; update the #533 test's comment, which currently explains the pass by "the literal carries a backslash match alias".
  4. test/path-normalizer.test.ts — "forBashToken keeps a non-mount POSIX absolute as a literal": matchValues() becomes ["/tmp/foo"]; update the explanatory comment.
  5. test/access-intent/bash/program.test.ts — "keeps a non-mount POSIX absolute as a literal rule candidate": same assertion change.

Docs:

  1. 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.
  2. docs/architecture/architecture.md — the wildcard-matcher.ts module-tree entry gains the active constraint (the win32 fold applies to both pattern and value; matching goes through CompiledWildcardPattern.matches() so it cannot be half-applied). The access-path.ts entry drops the forLiteral(literal, matchAliases?) signature and its parenthetical about the win32 backslash alias.
  3. 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 to external_directory and says the path surface still governs the device token, which a rule written as typed (path: { "/dev/null": "allow" }) now matches.
  4. .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

  1. 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 via win32PathFlavor — the whole path from BashProgram.parse through PermissionManager.check, which no existing test covers for the device shape.
  2. 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.
  3. Tests that must stay as-is. test/path/path-flavor.test.ts (matchOptions composition), test/rule.test.ts's existing win32 case-fold and backslash-value cases, and test/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

  1. 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 under windowsSeparators (/dev/null, /dev/*, src/* against src/foo.ts); the fold stays off by default; a win32 path rule /dev/null matches the value /dev/null while a bash rule does not fold; end to end, a win32 manager with ["*": ask, "/dev/null": allow] answers allow for the match values BashProgram.parse("echo hi > /dev/null", winNormalizer) produces, and ask without the rule. Green — extract foldSeparators and apply it to the value in wildcardMatch as 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)
  2. Matching moves onto the compiled pattern. Test surface: test/wildcard-matcher.test.ts. Red — compile with { windowsSeparators: true } and assert compiled.matches("/dev/null"); the method does not exist. Green — replace regex with matches(value) on CompiledWildcardPattern, route findCompiledWildcardMatch and wildcardMatch through 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 removed regex field must have no surviving reader. Commit: refactor(pi-permission-system): let the compiled wildcard pattern own matching (#653)
  3. Drop the redundant backslash match alias. Test surface: test/path-normalizer.test.ts, test/access-intent/bash/program.test.ts. Red — change both matchValues() assertions to ["/tmp/foo"]; they fail while the alias is still attached. Green — forBashToken's posix-absolute branch returns this.forLiteral(literal); drop the matchAliases parameter from PathNormalizer.forLiteral and AccessPath.forLiteral in 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)
  4. Documentation. No test surface. Update ADR 0003's Consequences bullet, the two architecture.md module-tree entries, the two configuration.md passages, and the package skill's alias guidance. Verify: pnpm exec rumdl check on the edited files, plus a grep for match alias / backslash alias across docs/ 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 allow that is the reported fix; for deny and ask it is strictly more restrictive, so the failure mode is a prompt, never a bypass. Mitigation: the behavior change is win32-only, stated in the fix: 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 regex with matches touches a semi-public shape. CompiledWildcardPattern is not re-exported from the package entry point and has no cross-extension consumer; its only production readers are inside wildcard-matcher.ts. Mitigation: pnpm run check in step 2, and the migration of all four test readers in the same commit.
  • The doc correction could read as a behavior regression. configuration.md currently promises that echo hi > /dev/null does not prompt. Mitigation: the corrected prose names the lever explicitly — the device is exempt from external_directory, and a path rule 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 path surface as well as external_directory? Decided out of scope for this issue (documentation corrected instead); revisit only if a user reports that an explicit path rule is an unreasonable requirement for /dev/null.
  • _compileWildcardPatterns, compileWildcardPatternEntries, and findCompiledWildcardMatchForNames have no production callers today — only tests. Not addressed here; pnpm fallow dead-code reports the package clean, so any pruning needs its own justification.
  • #655 carries the deriveApprovalPattern design question (ambient node:path read, and whether the derivation belongs on PathNormalizer, AccessPath, or SessionApproval).