fix(path): keep whitespace in Windows comparison paths - #78
Conversation
normalizeWindowsPathForComparison delegated its final step to normalizeLowercaseStringOrEmpty, a free-text string coercion helper that trims before lowercasing. Windows containment math therefore compared paths with their surrounding whitespace removed, so a space-padded root collapsed onto its unpadded sibling and isPathInside reported a file in the sibling directory as inside the root. Lowercase in place and keep separator and extended-length handling unchanged, so only the trimming behavior shifts.
|
Codex review: needs maintainer review before merge. Reviewed August 1, 2026, 8:15 PM ET / August 2, 2026, 00:15 UTC. ClawSweeper reviewWhat this changesThe PR stops Windows comparison normalization from trimming surrounding whitespace, adds containment regression coverage, and enables existing trailing-whitespace deny-policy tests on Windows. Merge readinessThis PR is a narrowly scoped, source-supported repair for a Windows comparison bug and includes credible real Windows before/after proof. It should remain open for a maintainer to explicitly confirm the security-boundary contract: padded Windows paths must compare as distinct lexical paths, even though some external callers may newly fail closed. Priority: P1 Review scores
Verification
How this fits together
flowchart LR
A[Roots, targets, and policy entries] --> B[Windows path normalization]
B --> C[Lexical containment comparison]
C --> D[Root confinement]
C --> E[Mutation deny policy]
D --> F[Filesystem operation]
E --> F
Decision needed
Why: The implementation is mechanically narrow and follows the repository’s fail-closed guidance, but it intentionally changes behavior for external callers that previously depended on an undocumented whitespace-trimming comparison. Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Adopt exact lexical Windows comparison semantics in the shared containment primitive; if a specific configuration surface should tolerate accidental whitespace, trim there explicitly rather than weakening the generic security predicate. Do we have a high-confidence way to reproduce the issue? Yes, source-reproducible with high confidence: current main sends Windows comparison keys through a helper that trims whitespace, and the PR documents a Windows exported-API before/after run plus load-bearing regression failures when the fix is reverted. Is this the best way to solve the issue? Yes, technically: preserving whitespace in the containment primitive is the narrowest repair and aligns with exact lexical comparison; the remaining question is maintainer confirmation of the external compatibility contract. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against ab933820c089. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (5 earlier review cycles)
|
|
Answering the maintainer decision the review flagged, since it is the only thing left on this PR. I intended option 1 — confirm fail-closed root semantics, and the patch is written for that reading: On the compatibility risk, to scope what actually changes:
Option 2 (normalize root input at the configuration boundary) is not mutually exclusive with this and I think it is the better long-term shape — but it belongs where roots are constructed, not inside a containment predicate, and it does not fix the predicate for callers who build paths themselves. Happy to open that as a follow-up if you want it. Also flagged in the PR body and worth repeating here: No further changes planned from my side — the branch is at |
Both cases were skipped on win32 because the trimming normalizer made a padded deny prefix compare equal to its unpadded sibling. With the comparison fixed they describe real Windows behavior, and on the old normalizer they fail for the reason that matters: the named directory stayed writable while the sibling was blocked.
|
Both remaining items are about the intended contract across boundary layers, so I measured it instead of arguing it. The contract is already yours, and it is pinned by committed tests. On Windows the deny list is currently applied to the wrong directory. Driving those two scenarios through the public That is stronger than the Given that, I dropped the Windows skip on those two cases. They are load-bearing there: Full Windows suite: On the compatibility item: every other in-package caller passes a realpath result ( CHANGELOG updated with the deny-mutations effect under the existing |
|
One correction to the note above, so the claim is not read as stronger than it is.
So the Windows numbers above are from my machine (Windows 11, Node 24.15.0), and the unskipped cases will only be enforced once you approve the run. If you would rather not spend a CI approval on it yet, the local before/after is reproducible with: |
|
Maintainer-side reproduction on the exact head Verified locally with Node 24.18.0 and pnpm 10.34.5:
Source review agrees with the patch shape: path comparison must preserve legal path characters, and any input convenience trimming belongs at a specific configuration boundary rather than inside the shared confinement predicate. This also makes the existing trailing-whitespace deny-policy contract enforceable on Windows in the fail-closed direction. Hosted Windows CI is still the remaining independent gate; the contributor's real Windows before/after proof covers the platform behavior meanwhile. |
What Problem This Solves
Fixes an issue where consumers relying on root confinement would have an outside path reported as inside the root when the root string carries surrounding whitespace. The affected surface is Windows path validation and root confinement:
isPathInsideis the predicate other guards build on, and onwin32its only normalization step isnormalizeWindowsPathForComparison.That function ends by delegating to
normalizeLowercaseStringOrEmpty(src/string-coerce.ts:31), which is a free-text string coercion helper — the same module also normalizes fast-mode flags and thread values. Its chain reachesnormalizeNullableString(src/string-coerce.ts:5-11), which callsvalue.trim(). So a path used for containment math is trimmed before it is lowercased, and leading or trailing whitespace silently disappears from the comparison key.Whitespace is a legal part of a Windows path component, so trimming merges two genuinely different directories into one comparison key:
C:\rootandC:\rootboth becomec:\root.Why This Change Was Made
The final step now lowercases in place instead of routing the path through the free-text coercion helper. Separator normalization and extended-length (
\\?\,\\?\UNC\) handling are untouched, so the only behavior that shifts is that surrounding whitespace is preserved. Case folding is deliberately left exactly as it was, since it is the documented comparison semantic and is covered by an existing assertion intest/api-coverage.test.ts.Non-goal, stated explicitly: this change does not alter the Unicode case-folding behavior.
toLowerCase()is not injective — on a Turkish-language Windows install,"İstanbul"folds to a 9-code-point string that never round-trips back, so two distinct NTFS names can still collapse onto one comparison key. Deciding whether comparison should move to an ASCII-only or locale-invariant fold changes behavior for every non-ASCII path, which reads as an owner decision rather than a bug fix. I left it out of this PR and can follow up separately if you want it addressed.User Impact
Consumers that derive a root from configuration or environment input — where a stray trailing space is easy to introduce — no longer get a false
truefromisPathInsidefor files that live outside that root. Paths without surrounding whitespace are unaffected, and no export, error shape, or default changes.Evidence
Reproduction on Windows 11 with Node 24.15.0, using the real exported functions:
Regression tests were added to
test/windows-path.test.ts: one platform-independent case for the normalizer (trailing space and a NBSP), onewin32-only pair forisPathInsidecovering both the padded root and a genuine descendant, plus an assertion that lowercasing, separator normalization, and extended-length stripping still behave as before.The new tests are load-bearing. Stashing only
src/path.tsand re-running the file against the unmodified normalizer:What the other
isPathInsidecallers do with this changeThe review asks for the intended Windows contract across boundary layers, so I inventoried every
in-package caller rather than reasoning about it:
src/archive-staging.ts:124,221,290,321,368destinationRealDir/sourceRootReal(realpath results)src/file-store-boundary.ts:64,192·src/file-store-prune.ts:54·src/file-store.ts:431rootReal/scopedRoot.rootWithSep(realpath results)src/file-store.ts:124rootDirsrc/install-path.ts:67path.resolve(baseDir)src/deny-mutations.ts:49,96,109,110denyMutationspolicy entriesA realpath result carries the on-disk spelling, so for every confinement caller the change is
either invisible or the intended fail-closed correction.
deny-mutationsis the one caller thatcompares free-text configuration, and there the current Windows behavior is worse than "lenient".
The deny-list case: Windows applies the policy to the wrong directory
The package already pins the intended contract, on POSIX, with two committed tests —
test/deny-mutations.test.tspreserves trailing whitespace in denied pathsand… in denied prefixes. Both carryit.skipIf(skipOnWindows), which is what the trimmingnormalizer forced. Driving those same two scenarios through the public
root()API on Windows 11(Node 24.15.0), with only
src/path.tsswapped between runs:So on Windows the deny list is not merely approximate today, it is applied to the wrong directory:
the protected path stays writable while its unprotected sibling is refused. The branch produces the
POSIX behavior the repository already documents.
With that established, the two cases no longer need the Windows skip, and they are load-bearing
there:
Full suite on Windows moves from
447 passed | 185 skippedto449 passed | 183 skipped— the twocases that started running, and nothing else. CI already covers
windows-lateston Node 22 and 24,so this is enforced rather than local-only. The remaining
skipIfin that file (symlink ancestors)is unrelated and untouched.
Validation performed on this branch:
pnpm exec vitest run test/windows-path.test.ts— 5 passedpnpm exec vitest run(full suite) — 447 passed, 185 skipped (632); 54 files passed, 6 skippedpnpm lint:file-size— passedpnpm lint:fs-boundary— passedpnpm build(tsc -p tsconfig.json) — passedgit diff --check— cleanCHANGELOG.mdhas an entry underUnreleased->Security and Correctness.Related:
openclaw/openclaw#109823, where this normalizer's lowercasing had to be worked around downstream; that PR documents the same helper being copied to obtain a case-preserving variant.pnpm checkgates run locallyCHANGELOG.mdupdated when release-relevant