Skip to content

fix(root): scope drive-relative destination guards - #97

Merged
steipete merged 1 commit into
mainfrom
fix/drive-relative-scope
Aug 2, 2026
Merged

fix(root): scope drive-relative destination guards#97
steipete merged 1 commit into
mainfrom
fix/drive-relative-scope

Conversation

@steipete

@steipete steipete commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

This finishes #85 on top of current main while preserving all four commits and authorship from @Yigtwxx.

  • Keep the unconditional per-segment guard in splitSafeRelativePath() and apply the same portable identity rule to every FileStore key.
  • Scope the Root guard to operations that choose a new destination: resolve, openWritable, append, mkdir, write/create and their JSON wrappers, copyIn, and the destination of move.
  • Keep existing-object Root operations compatible with POSIX filenames such as c:notes.txt: open/read variants, readAbsolute/reader, stat/exists/list/walk, remove, and the source of move.
  • Move isDriveRelativePath() into the unexported safe-path-segment module so @openclaw/fs-safe/path gains no public API.

Why this cut

The protected property is destination identity: two distinct untrusted names must not select the same newly resolved file on Windows. A Root read or inspection of an object already present does not create that identity, and rejecting it on POSIX would make a legal on-disk filename unreadable.

copyIn is intentionally guarded because its first argument is a new destination. That means a caller using an unsanitized path.basename() can see invalid-path for a POSIX source named c:foo.png; the caller should sanitize or explicitly map host basenames before choosing a portable destination. Quietly accepting the name would make staging host-dependent and could retarget it on Windows.

move(from, to) treats its arguments differently for the same reason: from identifies an existing object and remains readable on POSIX, while to creates the new name and is guarded.

Compatibility and docs

The changelog and Root, FileStore, and error references now describe exactly where drive-relative spellings are rejected and where legal POSIX drive-like filenames remain accepted. The entry credits @Yigtwxx.

Downstream sequencing note

In openclaw/openclaw, validateAttachmentName() at src/agents/subagent-attachments.ts:155-173 rejects separators, NUL/control characters, ., .., and .manifest.json, but not :. The untrusted subagent/ACP attachment name becomes a store key at line 329. With this fs-safe change, a drive-relative name now throws invalid-path, which is desirable fail-closed behavior; OpenClaw should add its own name check so the caller instead receives the domain-specific attachments_invalid_name failure.

Proof

  • Exact detached origin/main (8926c33) regression: 2/2 tests failed. splitSafeRelativePath("C:evil") did not throw, and Root.write("C:secret.txt", ...) resolved instead of rejecting.
  • Focused branch proof: test/windows-path.test.ts plus test/api-coverage.test.ts — 33 passed, 2 platform skips.
  • pnpm check — 63 files passed, 2 skipped; 683 tests passed, 32 skipped, including build and package validation.
  • pnpm test:security — 5 files and 64 tests passed.
  • git diff --check — clean.
  • Generated dist/path.d.ts contains no isDriveRelativePath declaration.
  • Shared Codex autoreview (gpt-5.6-sol, high reasoning) — clean, no accepted/actionable findings.

@steipete
steipete requested a review from a team as a code owner August 2, 2026 22:13
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.

Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com>
@steipete
steipete force-pushed the fix/drive-relative-scope branch from 1fee471 to 6797023 Compare August 2, 2026 22:19
@clawsweeper

clawsweeper Bot commented Aug 2, 2026

Copy link
Copy Markdown

ClawSweeper status: review started.

I am starting a fresh review of this pull request: fix(root): scope drive-relative destination guards This is item 1/1 in the current shard. Shard 0/1.

This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking.

Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted.

@steipete
steipete merged commit eda9a61 into main Aug 2, 2026
22 checks passed
@steipete
steipete deleted the fix/drive-relative-scope branch August 2, 2026 22:22
steipete added a commit that referenced this pull request Aug 2, 2026
Stress testing archive extraction found six defects.

Drive-relative and NUL archive paths bypassed validation entirely, so a ZIP
entry named C:secret.txt or nested/C:secret.txt was accepted — the archive-side
sibling of the drive-relative aliasing closed for Root and FileStore in #97.
Explicit zero limits silently became defaults, so maxEntries: 0 did not mean
zero, and NaN disabled the bounded stream cap. The JavaScript ZIP reader and
bounded reads accepted CRC and size mismatches. The JavaScript TAR reader
ignored stripped entries when enforcing maxEntries while the native reader
counted them. Duplicate names and post-strip output collisions behaved
differently depending on which backend ran.

Documentation implied secret readers enforce 0o600. They do not: writes enforce
modes, and permission-checked reading is readSecureFile.

Walk cycle prevention and failedDirs, secret hardlink and pinned-read ordering,
secure temp name generation and mode-at-creation, and trash confinement were all
verified clean.
steipete added a commit that referenced this pull request Aug 2, 2026
Stress testing path validation and root confinement found seven defects.

Intermediate symlinks bypassed the documented default reject policy, so
root.readText("alias/file.txt") returned contents through a directory symlink.
The policy was only ever enforced on the final component. device-path
precedence is deliberately preserved for lexically explicit unsafe namespaces
such as /dev/fd, which are themselves symlinks on common Linux hosts; ordinary
intermediate aliases now report symlink.

A deterministic parent-directory swap returned the replacement bytes instead of
path-mismatch; the pre-open path identity is now compared against the opened
descriptor. A raced FIFO could pin a read worker, so reads open nonblocking
where supported and reject it as not-file. Padded FileStore keys silently
aliased unpadded keys and now throw invalid-path.
sanitizeUntrustedFileName("CON .") still produced a Windows device name.
Followed intermediate symlink loops reported outside-workspace rather than the
documented symlink code. Overlong Root inputs were misclassified as escapes and
now report invalid-path.

Documentation claimed all absolute and .. inputs are rejected and that filename
sanitization preserves surrounding spaces; neither was true.

The drive-relative guarded and unguarded split introduced in #97 was verified
correct, with coverage extended across every Root wrapper, both move directions,
and all async and sync FileStore key methods.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant