Skip to content

fix(path): reject drive-relative untrusted relative paths - #85

Closed
Yigtwxx wants to merge 4 commits into
openclaw:mainfrom
Yigtwxx:fix/reject-drive-relative-paths
Closed

fix(path): reject drive-relative untrusted relative paths#85
Yigtwxx wants to merge 4 commits into
openclaw:mainfrom
Yigtwxx:fix/reject-drive-relative-paths

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where consumers passing untrusted relative paths through splitSafeRelativePath() / resolveSafeRelativePath() would have two distinct inputs resolve to the same file on Windows, because a drive-relative spelling such as C:secret.txt was accepted as an ordinary segment.

The affected surface is path validation, and through it the file stores. This is a deliberate compatibility change: input that previously resolved silently now throws invalid-path. That is called out here rather than at the bottom because CONTRIBUTING.md names path handling as a public compatibility surface.

splitSafeRelativePath() (src/path.ts:138-161) rejects NUL bytes, backslashes, absolute paths, // prefixes, and .. segments. Absolute detection uses path.win32.isAbsolute(), which returns false for C:name — that spelling is relative to the drive's own working directory, not to the root. The segment is therefore returned unchanged, and resolveSafeRelativePath() hands it to path.resolve(), which does consume the drive prefix.

Measured on Node v22.20.0 / win32:

path.win32.resolve("C:\\root", ...segments)

["C:evil"]        -> C:\root\evil     aliases "evil"
["a","C:b"]       -> C:\root\a\b      aliases "a/b"
["x","C:y","z"]   -> C:\root\x\y\z
["a","C:.."]      -> C:\root
["C:.."]          -> C:\              escape, caught later by isPathInside
["a","D:b"]       -> D:\a\b           escape, caught later by isPathInside

path.posix.resolve() keeps these as literal segments, so the aliasing bites on Windows only — but the divergence between the two platforms is itself the problem for anything replicating store contents across hosts.

Two consequences beyond the aliasing:

  • "C:.." passes the .. check as the single segment C:.., so the function violated its own postcondition (relative path must not contain '..') and returned a ..-bearing segment to any caller that joins segments itself. resolveSafeRelativePath()'s trailing isPathInside() still caught the escape, so this was a contract gap rather than a traversal.
  • src/file-store.ts:111-120 routes untrusted store keys through assertRelativePath(), which rewrites \ to / before validation. That makes "C:\\x" safe (it becomes C:/x, which path.win32.isAbsolute() catches) while "C:evil" and "a\\C:b" pass straight through — so the whole fileStore / fileStoreSync key surface was affected.

Why This Change Was Made

One guard in src/path.ts: any segment matching /^[A-Za-z]:/ is rejected with the existing invalid-path code.

Design decisions:

  • Per segment, not on the whole string. ["a","C:b"] -> C:\root\a\b above: a drive-relative spelling anywhere in the path aliases the whole prefix, and "./C:evil" has its . filtered out before resolution. A leading-segment-only check would leave the hole open.
  • Existing error code reused. No change to the FsSafeErrorCode union, docs/types.md, or any option shape. The message follows its two siblings in the same loop (relative path must not contain '..', relative path must not be absolute).
  • Placed after the .. check so "C:.." reports the drive-letter cause, and the function can no longer return a ..-bearing segment.
  • Unconditional, not platform-gated. The function already calls path.win32.isAbsolute() on every host, so its contract is "reject what is unsafe on any supported platform." Gating on process.platform would make a store key valid on Linux and a boundary violation on Windows, which is worse for anyone syncing store contents across hosts.
  • Linear. The pattern is anchored with no backtracking, consistent with the linear-time sanitizer work in ab93382.

Non-goal: broader colon handling (NTFS alternate data streams, file.txt:stream) is a separate concern and is not touched here.

Compatibility scan: grep -rnE '"[A-Za-z]:[^\\/]' src/ test/ docs/ returns nothing — no in-package caller passes a drive-relative relative path. Externally this would break a consumer deliberately using single-letter-plus-colon filename prefixes as store keys. Those are already illegal filenames on Windows, and on Windows the previous behavior wrote them to the wrong file, so the breakage surfaces a bug rather than removing a working feature.

User Impact

fileStore, fileStoreSync, and any direct user of @openclaw/fs-safe/path now reject drive-relative keys instead of silently aliasing them:

const store = fileStore({ rootDir });
await store.writeText("secret.txt", "real");
await store.writeText("C:secret.txt", "aliased"); // now: invalid-path

Before this change the second call resolved to the same file and overwrote it. A caller that maintained its own allow/deny bookkeeping per relative path could be fed the C:-prefixed spelling to reach a key it had already decided to protect.

No API, option, default, export, or error code was added or removed.

Evidence

Reproduction on main, from the new store-level regression test:

FAIL  test/windows-path.test.ts > drive-relative relative paths >
      stops a drive-relative store key from aliasing a plain key
AssertionError: promise resolved
  "'C:\Users\...\fs-safe-drive-relative-fR1Nlm\secret.txt'"
  instead of rejecting

The pre-fix run does not merely fail to throw — it returns the path of secret.txt, confirming the second write landed on the first key's file.

Regression coverage added to test/windows-path.test.ts (three tests) and test/api-coverage.test.ts (existing rejection array extended in place, no new lines):

  1. rejection of C:evil, c:evil, C:, C:.., C:evil/sub, a/C:b, ./C:evil, a/D:b
  2. a no-false-positive case — logs/2026-08-02T10:30:00Z.log still parses normally, so the anchored single-letter pattern does not reject timestamped filenames
  3. the mkdtemp real-disk store case quoted above

None are platform-skipped. The guard is unconditional, so the assertions hold on all six CI matrix legs; only the pre-fix failure mode was Windows-specific.

Validation on Windows 11, Node v22.20.0, pnpm 10.34.5:

pnpm check
  lint:file-size    pass
  lint:fs-boundary  pass
  build             pass
  test              Test Files  54 passed | 6 skipped (60)
                    Tests  490 passed | 183 skipped (673)
  check-pack.mjs    exit 0

pnpm test:security  Test Files  5 passed (5)
                    Tests  45 passed | 17 skipped (62)

targeted           test/windows-path.test.ts  8 passed (8)
                   (5 on main before the three new tests)

One note on the local run: test/file-lock-reentrancy.test.ts failed once with EPERM on a .lock file during an earlier full-suite pass, then passed 4/4 in isolation and in the clean re-run above. It is unrelated to this change — src/sidecar-lock.ts does not use either function touched here — and looks like a Windows file-handle race from the separate-process arbitration test.

  • Tests added or updated when behavior changed
  • Security and compatibility impact considered
  • CHANGELOG.md updated when release-relevant
  • No credentials, private paths, private hosts, or sensitive contents included

Update — 2a10498

readAbsolute() and reader() bypassed the guard: readPathInRoot() resolved the raw input against the root before validating it, so C:secret.txt reached the downstream check already flattened to secret.txt.

Fixed by validating the raw input at the top of readPathInRoot(), before the first path.resolve(). Drive-absolute in-root paths are unaffected.

Pre-fix, on head 7024025 with the two entry points added to the rejection table:

FAIL  test/windows-path.test.ts > drive-relative relative paths >
      rejects a drive-relative key on every root and store entry point
AssertionError: promise resolved "{ …(4) }" instead of rejecting
+   "realPath": "<tmp>\fs-safe-drive-relative-delegates-XXXXXX\secret.txt",

Coverage now spans 15 entry points, and the positive case pins readAbsolute() and reader() on an absolute in-root path.

Validation on Windows 11, Node v22.20.0:

lint:file-size    pass
lint:fs-boundary  pass
tsc --noEmit      pass
vitest run        Tests  492 passed | 183 skipped (676)
                  1 failure: file-lock-reentrancy.test.ts (pre-existing flake,
                  5 passed | 2 skipped on isolated re-run)
test:security     Tests  45 passed | 17 skipped (62)
targeted          test/windows-path.test.ts  11 passed (11)

The portable-versus-Windows-only compatibility decision is still open for the repository owner; see the discussion comment.

splitSafeRelativePath() rejected absolute paths via path.win32.isAbsolute(),
which reports false for the drive-relative spelling "C:name". The segment was
returned unchanged and path.resolve() then consumed the drive prefix, so on
Windows "C:secret.txt" and "secret.txt" resolved to the same file. Measured
with path.win32.resolve("C:\root", ...):

  ["C:evil"]   -> C:\root\evil
  ["a","C:b"]  -> C:\root\a\b
  ["a","D:b"]  -> D:\a\b

Reject any segment starting with a drive letter, checked per segment because
a drive-relative spelling anywhere in the path aliases the whole prefix. The
check is unconditional, matching the existing path.win32.isAbsolute() guard.
@Yigtwxx
Yigtwxx requested a review from a team as a code owner August 2, 2026 07:35
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. labels Aug 2, 2026
@clawsweeper

clawsweeper Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codex review: found issues before merge. Reviewed August 2, 2026, 2:19 PM ET / 18:19 UTC.

ClawSweeper review

What this changes

Reject Windows drive-relative spellings such as C:name before safe relative-path parsing and Root file-access methods resolve them, document the resulting invalid-path error, and add regression coverage.

Merge readiness

⚠️ Needs maintainer review before merge - 5 items remain

Keep this PR open. It fixes the previously identified raw-input bypasses and has credible Windows runtime proof, but it still accidentally expands the published @openclaw/fs-safe/path API and needs an explicit maintainer choice on the deliberate cross-platform compatibility change.

Priority: P1
Reviewed head: 8d82fb7da225828840c6b8008af0cb96c9fa141f
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🦐 gold shrimp (3/6) The Windows runtime proof and regression coverage are strong, but the unintended public export and unresolved cross-platform contract prevent merge readiness.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR includes a Windows before/after terminal reproduction, real-disk coverage of the changed entry points, and supplied successful hosted Windows checks.
Patch quality 🦐 gold shrimp (3/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR includes a Windows before/after terminal reproduction, real-disk coverage of the changed entry points, and supplied successful hosted Windows checks.
Evidence reviewed 6 items Current-main defect: Current main accepts a relative path after only NUL validation at the Root funnel, while splitSafeRelativePath() has no drive-relative check before it passes segments to path.resolve(). This supports the reported Windows aliasing mechanism.
Branch closes the Root ingress gaps: The branch validates the raw readPathInRoot() input before resolution and extends Root-relative validation with the shared drive-relative predicate.
Regression coverage: The branch exercises 15 direct Root and delegated file-store calls with a drive-relative key, checks invalid-path, preserves the original file contents, and separately retains absolute in-root reads.
Findings 1 actionable finding [P2] Keep the drive-relative predicate out of the public path API
Security None None.

How this fits together

@openclaw/fs-safe accepts untrusted path strings through public path helpers, Root handles, and file-store delegates. Those paths are validated and resolved into confined filesystem operations, so raw input must be checked before platform-specific resolution can alias one key to another.

flowchart LR
  A[Untrusted path input] --> B[Safe path helper]
  A --> C[Root or file-store API]
  B --> D[Segment validation]
  C --> E[Raw Root input validation]
  D --> F[Constrained path resolution]
  E --> F
  F --> G[Read write or metadata operation]
Loading

Decision needed

Question Recommendation
Should Root and file-store methods reject drive-relative spellings such as C:name on every supported platform, although those names are legal literal filenames on POSIX? Adopt a portable strict contract: Reject the spelling on every platform so a store key cannot be valid on POSIX while being a Windows boundary hazard.

Why: The Windows alias is source-proven, but choosing portable strict rejection versus a Windows-only guard defines a public compatibility policy that cannot be resolved mechanically.

Before merge

  • Keep the drive-relative predicate out of the public path API (P2) - @openclaw/fs-safe/path is a published package subpath, so this export promotes an internal Root-validation detail into generated declarations without docs or a compatibility commitment. Make the predicate module-private, or explicitly document and support it, before merge.
  • Resolve merge risk (P1) - Merging changes the public Root and file-store input contract on every platform: a literal POSIX filename such as C:name would now fail with invalid-path.
  • Resolve merge risk (P1) - The new exported predicate would become part of the supported @openclaw/fs-safe/path declaration surface without documentation or an intentional compatibility commitment.
  • Complete next step (P2) - A maintainer must decide the public portable-input contract before the small internal-helper repair can safely land.

Findings

  • [P2] Keep the drive-relative predicate out of the public path API — src/path.ts:142-144
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Changed surface 8 files affected; 145 additions, 5 deletions The patch spans public documentation, path parsing, Root validation, and focused regression coverage.
Boundary coverage 15 entry points asserted The new table covers direct Root APIs and file-store delegates, including the previously missed absolute-read path.

Merge-risk options

Maintainer options:

  1. Internalize the helper and approve strict portability (recommended)
    Move the predicate out of the published path subpath, then merge portable rejection after a maintainer confirms that contract.
  2. Preserve POSIX filename compatibility
    Constrain the guard to Windows if retaining literal POSIX C:name filenames is the intended public behavior.

Technical review

Best possible solution:

Keep a shared drive-relative predicate internal to the guarded filesystem implementation, then adopt the portable strict rejection only if the repository owner explicitly wants one cross-platform key contract; otherwise gate the rejection to Windows and document that platform distinction.

Do we have a high-confidence way to reproduce the issue?

Yes. Current-main source establishes the unsafe resolution sequence, and the PR supplies a Windows before/after real-disk reproduction across the affected APIs.

Is this the best way to solve the issue?

No. The validation placement now covers the demonstrated ingress paths, but the shared predicate should not become a public path-helper API and the portable compatibility policy needs owner approval.

Full review comments:

  • [P2] Keep the drive-relative predicate out of the public path API — src/path.ts:142-144
    @openclaw/fs-safe/path is a published package subpath, so this export promotes an internal Root-validation detail into generated declarations without docs or a compatibility commitment. Make the predicate module-private, or explicitly document and support it, before merge.
    Confidence: 0.98

Overall correctness: patch is incorrect
Overall confidence: 0.96

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 2477f5681f68.

Labels

Label justifications:

  • P1: The patch addresses a public root-confinement alias that can make two distinct Windows inputs target the same file.
  • merge-risk: 🚨 compatibility: The proposed policy rejects input that remains a valid literal filename on POSIX.
  • merge-risk: 🚨 security-boundary: The change modifies validation ahead of public filesystem-root resolution.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (terminal): The PR includes a Windows before/after terminal reproduction, real-disk coverage of the changed entry points, and supplied successful hosted Windows checks.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR includes a Windows before/after terminal reproduction, real-disk coverage of the changed entry points, and supplied successful hosted Windows checks.

Evidence

What I checked:

  • Current-main defect: Current main accepts a relative path after only NUL validation at the Root funnel, while splitSafeRelativePath() has no drive-relative check before it passes segments to path.resolve(). This supports the reported Windows aliasing mechanism. (src/path.ts:138, 2477f5681f68)
  • Branch closes the Root ingress gaps: The branch validates the raw readPathInRoot() input before resolution and extends Root-relative validation with the shared drive-relative predicate. (src/root-impl.ts:684, 2a10498bcc9f)
  • Regression coverage: The branch exercises 15 direct Root and delegated file-store calls with a drive-relative key, checks invalid-path, preserves the original file contents, and separately retains absolute in-root reads. (test/windows-path.test.ts:70, 8d82fb7da225)
  • Published API expansion: The package publishes ./path, and the new export function isDriveRelativePath would therefore enter generated declarations as a supported public API although the patch only needs a shared internal guard. (src/path.ts:142, 8d82fb7da225)
  • Feature provenance: The current Root safety surface traces through Peter Steinberger's reusable safe-filesystem primitives and later cross-platform containment hardening; the active branch follows recent Windows path work by Yigtwxx. (src/root-impl.ts:1, 5ddca800c6aa)
  • Release status: The current base is in v0.5.1; the PR head is a separate unmerged commit and is not in that release. (package.json:1, 2477f5681f68)

Likely related people:

  • Peter Steinberger: Local history attributes the foundational Root safety primitives and cross-platform containment work to Peter, and current-main blame routes the relevant public path and Root code through that history. (role: feature owner and recent area contributor; confidence: high; commits: 5ddca800c6aa, 37497312750d, 16e1bd489ae8; files: src/path.ts, src/root-context.ts, src/root-impl.ts)
  • Yigtwxx: Yigtwxx authored the active drive-relative repair and the recently merged adjacent Windows path-normalization work, providing relevant implementation context without making the contributor the sole decision owner. (role: recent merged adjacent contributor; confidence: high; commits: cf4e29747ada, 4d7ccad54225, 7024025e7420; files: src/path.ts, src/root-context.ts, src/root-impl.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Make the drive-relative predicate internal unless it is intentionally documented as supported.
  • Obtain maintainer confirmation for portable strict rejection versus Windows-only behavior.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (8 earlier review cycles)
  • reviewed 2026-08-02T07:39:19.037Z sha 4d7ccad :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T09:40:37.738Z sha 4d7ccad :: needs changes before merge. :: [P1] Validate every file-store key path
  • reviewed 2026-08-02T10:18:35.741Z sha 7024025 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T12:47:16.856Z sha 7024025 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T14:29:54.577Z sha 7024025 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T16:41:01.317Z sha 7024025 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T17:37:21.192Z sha 7024025 :: needs real behavior proof before merge. :: [P1] Reject drive-relative input before readAbsolute() resolves it
  • reviewed 2026-08-02T18:01:11.608Z sha 2a10498 :: found issues before merge. :: [P2] Keep the drive-relative predicate out of the public path API

@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Two notes before review.

The red Node 24 Windows leg is a pre-existing flake, not this branch. Node 22 on windows-latest passed on this same commit, and the failure is:

FAIL test/new-primitives.test.ts > secure file reads
     > reads from a validated Windows ACL and owner

That exact test also failed on main in the chore(release): open 0.5.2 changelog (#82) run. More broadly, ci.yml on main has been red for the last four merges (#79, #80, #82, #83), with a different concurrency or file-handle test failing each time, on Windows and ubuntu alike. The last green run on main is #78.

This change is a pure string guard in splitSafeRelativePath() and cannot reach Windows ACL reading. Same-commit evidence: Node 22 Windows, both ubuntu legs, both macOS legs, all native checks, package smoke, and CodeQL are green.

Ordering with #84. Both PRs add a bullet under a ### Security and Correctness heading in ## Unreleased, and that heading does not exist yet, so whichever lands first creates it and the other will need a trivial CHANGELOG.md rebase. #84 is the lower-risk one — its documentation was already correct — so landing that first and having me rebase this one is the cleaner order. The two touch disjoint source files (src/root-errors.ts + src/root-impl.ts there, src/path.ts here), so there is no other overlap.

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 2, 2026
The first commit guarded splitSafeRelativePath(), but Root never calls it:
assertValidRootRelativePath() only checked for NUL bytes, and
resolvePathInRoot() then ran path.resolve(root, "C:secret.txt"), which
consumes the drive prefix. Every Root method, and every fileStore() key that
delegates to one (open, the read variants, remove, exists), still aliased.

Measured on Windows before this change, with the new regression test:

  AssertionError: promise resolved to a ReadResult instead of rejecting
  + realPath: <tmp>\fs-safe-drive-relative-delegates-XXXXXX\secret.txt

Move the predicate into isDriveRelativePath() and apply it in
assertValidRootRelativePath(), the funnel every Root method reaches directly
or through resolvePathInRoot(). The regex now excludes drive-absolute
spellings such as C:\root\file.txt, which Root accepts today when they stay
inside the root, and a positive test pins that. The per-segment rule stays in
splitSafeRelativePath(), where segments become separate path.resolve()
arguments and an embedded "a/C:b" really does alias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfSdVPKK9F2JvDqpkzS3ND
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 7024025 for the P1 finding. The delegate gap is real, but it sits one layer below where the review located it.

The hole is the Root ingress, not the file-store ingress. fileStore.open, the read variants, remove, and exists do reach Root with only assertRelativePath() — but so does every direct Root caller, and Root never calls splitSafeRelativePath() at all. assertValidRootRelativePath() (src/root-context.ts:18) checked for NUL bytes only, and resolvePathInRoot() then ran path.resolve(root, "C:secret.txt").

Measured on the previous head 4d7ccad with the new regression test:

FAIL test/windows-path.test.ts > drive-relative relative paths
     > rejects a drive-relative key on every root and store entry point
AssertionError: promise resolved "{ …(4) }" instead of rejecting
+   "realPath": "<tmp>\fs-safe-drive-relative-delegates-XXXXXX\secret.txt",
+   "buffer": "real"  (4 bytes)

root.read("C:secret.txt") returned secret.txt — the same aliasing the write path already rejected.

What changed

isDriveRelativePath() now lives in src/path.ts and is applied in assertValidRootRelativePath(), the funnel every Root method reaches directly or through resolvePathInRoot(), and therefore every file-store delegate. src/file-store.ts is untouched.

One deliberate narrowing: the pattern is ^[A-Za-z]:(?![\\/]) rather than ^[A-Za-z]:. Root accepts drive-absolute input that stays inside the root today (documented at docs/root.md:196), so the guard rejects only the drive-relative spelling that actually aliases. Behavior inside splitSafeRelativePath() is unchanged — segments there never contain a separator, so the lookahead never fires and C:evil, C:, C:.., a/C:b, ./C:evil all still throw.

Where I did not follow the recommendation

Routing every file-store key through splitSafeRelativePath() at the store ingress. I implemented that first, and it failed test/new-primitives.test.ts > private file store mode > rejects paths outside the store root: store.readTextIfExists("../escape.txt") moved from outside-workspace to invalid-path. That is a second compatibility change, to a code the docs already assign to .. inputs, and outside this PR's subject — so I reverted it. With the guard on the shared funnel it is also unnecessary: the drive-relative key is now rejected identically on every store method.

The asymmetry that remains is embedded segments (a/C:b) — rejected by the resolving write path, accepted as a literal name by the delegating read path. That one does not alias, because Root resolves the key as a single string and the lexical traversal joins segments with path.join():

path.win32.resolve("C:\root\", "C:b")    ->  C:\root\b       (prefix consumed)
path.win32.resolve("C:\root\", "a/C:b")  ->  C:\root\a\C:b   (literal segment)

Happy to extend the rejection to embedded segments if you prefer the stricter single contract, but it widens the compatibility surface on POSIX, where C:b is an ordinary filename.

Coverage

In test/windows-path.test.ts:

  • a real-disk case asserting invalid-path on 13 entry points — root.read, readText, open, stat, exists, list, remove, write, move, plus store.readText, open, exists, remove — and that secret.txt still holds its original contents afterwards;
  • a positive case pinning that an absolute in-root path still reads, so the narrowed pattern cannot regress into rejecting C:\root\file.txt;
  • a platform-independent unit assertion separating drive-relative from drive-absolute spellings.

CHANGELOG.md and docs/root.md now state the contract in terms of Root methods rather than store keys alone.

Validation

Windows 11, Node v22.20.0, pnpm 10.34.5:

pnpm check
  lint:file-size    pass
  lint:fs-boundary  pass
  build             pass
  test              Test Files  54 passed | 6 skipped (60)
                    Tests  493 passed | 183 skipped (676)
  check-pack.mjs    exit 0

pnpm test:security  Test Files  5 passed (5)
                    Tests  45 passed | 17 skipped (62)

git diff --check    clean

The acceptance criteria list is covered: test/windows-path.test.ts and test/api-coverage.test.ts both pass inside that run.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. proof: sufficient Contributor real behavior proof is sufficient. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 2, 2026
@clawsweeper clawsweeper Bot added the status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. label Aug 2, 2026
readAbsolute() and reader() enter through readPathInRoot(), which resolved
the raw input against the root before any validation ran. On Windows that
consumed a drive-relative prefix, so C:secret.txt reached readFileInRoot()
as secret.txt and aliased the plain key the rest of the branch already
rejected.

Validate the raw input first. The check is the drive-relative one only, so
drive-absolute paths inside the root keep working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1kwRo32A6sGU1tbMFEFk
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 2a10498 for the P1. The finding is correct — readAbsolute() and reader() did bypass the guard, and my previous claim of "every Root entry point" was wrong because those two do not enter through resolvePathInRoot().

The hole

readPathInRoot() (src/root-impl.ts:686) resolved the raw input before any validation:

const candidatePath = path.isAbsolute(params.filePath)
  ? path.resolve(params.filePath)
  : path.resolve(rootDir, params.filePath);
const relativePath = path.relative(rootDir, candidatePath);

path.win32.isAbsolute("C:secret.txt") is false, so the drive prefix was consumed by path.resolve(rootDir, …) and relativePath was already the plain secret.txt by the time readFileInRoot() — and therefore assertValidRootRelativePath() — saw it. reader() delegates to readAbsolute(), so both were affected.

Measured on the previous head 7024025 with the two new entry points added to the existing table-driven test:

FAIL  test/windows-path.test.ts > drive-relative relative paths >
      rejects a drive-relative key on every root and store entry point
AssertionError: promise resolved "{ …(4) }" instead of rejecting
+   "realPath": "<tmp>\fs-safe-drive-relative-delegates-XXXXXX\secret.txt",

root.readAbsolute("C:secret.txt") returned a ReadResult for secret.txt — the alias this PR is meant to remove, on a public API.

The fix

assertValidRootPathInput() in src/root-context.ts runs on the raw input at the top of readPathInRoot(), before the first path.resolve(). It shares isDriveRelativePath() with the relative-path funnel and differs only in its message, since the input here is documented as absolute.

The narrowed pattern ^[A-Za-z]:(?![\/]) still matters here: readAbsolute()'s documented use is a drive-absolute in-root path (docs/root.md:196), so C:\root\file.txt must keep working while C:file.txt must not.

Coverage

In test/windows-path.test.ts:

  • root.readAbsolute and root.reader() added to the rejection table, taking it to 15 entry points, with the same post-check that secret.txt still holds its original contents;
  • the positive case extended so readAbsolute() and reader() are each pinned on an absolute in-root path, which is what would catch a future over-broad pattern.

No new test file, no platform skip — the guard is unconditional, so both run on all six legs.

Validation

Windows 11, Node v22.20.0:

lint:file-size    pass
lint:fs-boundary  pass
tsc --noEmit      pass
vitest run        Test Files  53 passed | 6 skipped | 1 failed (60)
                  Tests  492 passed | 183 skipped (676)
                  failure: file-lock-reentrancy.test.ts >
                           continues to arbitrate with a separate process
                  re-run in isolation: 5 passed | 2 skipped
test:security     Test Files  5 passed (5)
                  Tests  45 passed | 17 skipped (62)
targeted          test/windows-path.test.ts  11 passed (11)

That lock failure is the same pre-existing Windows flake described above — a separate-process arbitration test that has been failing intermittently on main since #79 and passes on re-run. Nothing in this change is reachable from src/sidecar-lock.ts.

On the ownership decision

Still yours to make, and it is worth stating plainly: rejecting C:name unconditionally makes a legal POSIX filename invalid as a Root or store key on every platform.

The alternative is gating the guard on process.platform === "win32", which I did not take because it makes the same key valid on Linux and a boundary violation on Windows — the worst outcome for anyone replicating store contents across hosts, and a contract that cannot be reasoned about from the docs alone. The current function already calls path.win32.isAbsolute() on every platform, so the portable-strict reading is the one the code was written against.

If you would rather keep POSIX permissive, say so and I will move the check behind a platform gate and adjust the tests and docs accordingly — it is a small change either way, but it should be your call rather than mine.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Aug 2, 2026
The drive-relative absolute-path test opened a root at the raw mkdtemp()
result, which is /var/... on macOS and an 8.3 short name on Windows CI.
openRoot() resolves the root, so the unresolved path the test passed back
in read as outside-workspace and failed every non-Linux leg. main fails an
equivalent repro the same way, so this is the test skipping the realpath
convention the rest of the suite follows, not a regression in the
drive-relative rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dpz7Mnp2xh7WHWHVAnPWtK
steipete added a commit that referenced this pull request Aug 2, 2026
path.win32.isAbsolute() reports false for the drive-relative spelling C:name
while path.resolve() still consumes the prefix, so C:secret.txt aliased
secret.txt on Windows and C:.. slipped past the .. check, letting
splitSafeRelativePath() violate its own documented postcondition.

The rejection applies where a path is created or resolved: safe-relative
parsing, every FileStore key, Root resolve, write, create, append,
openWritable, mkdir, copyIn, and the destination of move. It does not apply to
operations on an object that already exists — reads, stat, exists, list, walk,
remove, and the source of move — because c:notes.txt is a legal POSIX filename
and refusing to read a file that exists is collateral rather than containment.

isDriveRelativePath stays internal and is absent from the published ./path
subpath.

Supersedes #85.

Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com>
@steipete

steipete commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Landed as eda9a61 via #97 — thank you, and your four commits are credited on the squash.

Your analysis was exactly right, including the parts that are easy to miss. path.win32.isAbsolute() reporting false for C:name while path.resolve() still consumes the prefix is a genuinely subtle aliasing bug, and you were also right that C:.. made the function violate its own documented postcondition. The measured resolution table in your description made this reviewable in a way a prose explanation would not have.

You argued for an unconditional guard rather than gating on process.platform, on the grounds that a key valid on Linux must not become a boundary violation on Windows when store contents move between hosts. We agreed, and kept it unconditional.

One change before landing. The guard had also been placed in assertValidRootRelativePath(), which is reached from roughly ten Root methods including plain reads, stat and list. On POSIX, c:notes.txt is a legal filename, so as written it refused to read back a file that already exists on disk — which is collateral rather than containment, since aliasing only bites when a path is resolved into a new location.

So the rejection now applies where a path is created or resolved: safe-relative parsing, every FileStore key, resolve(), write, create, append, openWritable, mkdir, copyIn, and the destination of move(). It does not apply to reads, stat, exists, list, walk, remove, or the source of move().

I also moved isDriveRelativePath into an internal module. src/path.ts is the published ./path subpath, so exporting it there quietly widened the package's public API. That one was nobody's fault — nothing in the test suite pinned the export surface, which is why it went unnoticed. There is now a guard for exactly that (#96), and it verifies the predicate stays internal.

Worth flagging downstream: in openclaw/openclaw, validateAttachmentName() at src/agents/subagent-attachments.ts:155-173 rejects /, \, NUL, control characters, ., .. and .manifest.json but not :, and that name becomes a store key from untrusted attachment payloads. Your change turns that into an invalid-path throw, which is the correct outcome — it just wants its own check there so the failure surfaces as attachments_invalid_name.

@steipete steipete closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants