Skip to content

fix(root): create writable-open parents through the guarded mkdir helper#61

Open
yetval wants to merge 1 commit into
openclaw:mainfrom
yetval:fix/openwritable-guarded-mkdir
Open

fix(root): create writable-open parents through the guarded mkdir helper#61
yetval wants to merge 1 commit into
openclaw:mainfrom
yetval:fix/openwritable-guarded-mkdir

Conversation

@yetval

@yetval yetval commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

root().append() and root().openWritable() (and copyIn() when the Python helper is off or unavailable) create the destination's parent directories with a single recursive fs.mkdir. If a component of that parent path is replaced with a symlink pointing outside the root while the call is in flight, the mkdir follows it and creates directories outside the root.

The caller is not told. The post-open boundary check still fires, so the call throws not-found or outside-workspace and the caller believes the operation was blocked, while the directories created outside the root persist. That gap between "this was rejected" and "state was left behind outside the root" is the user-visible problem.

Scope, stated precisely: what escapes is unauthorized directory creation, plus a 0-byte file on the copyIn fallback. Attacker-controlled content does not land outside the root, because the post-open isPathInside(rootWithSep, realPath) check plus the cleanup path catch that. This is not arbitrary write and not a full sandbox escape.

The trigger is the same-user parent-swap race the library already defends against elsewhere, and which SECURITY.md and the guarded primitive treat as in scope.

Root cause

src/root-impl.ts:825-830 in openWritableFileInRoot, before this change:

  if (params.mkdir !== false) {
    const parentGuard = await createNearestExistingDirectoryGuard(rootReal, path.dirname(resolved));
    await withAsyncDirectoryGuards([parentGuard], async () => {
      await fs.mkdir(path.dirname(resolved), { recursive: true });
    });
  }

Node's recursive mkdir follows symlinks in components that already exist. The only protection here is a guard on the nearest existing ancestor, and that guard compares that ancestor's dev/ino and realpath. Adding a symlink child to that ancestor changes neither, so both the pre-mutation and post-mutation guard assertions pass while the mkdir walks out of the root.

src/guarded-mkdir.ts:60-62 documents this exact hazard as the reason the guarded primitive exists:

    // Node's recursive mkdir follows symlinks in missing components. Build one
    // segment at a time and realpath-check each segment before descending.

A pre-existing out-of-root symlink parent is already rejected by assertNoPathAliasEscape. Only the swap window between that check and the mkdir is exposed, which is why this needs a race to reach.

Fix

  if (params.mkdir !== false) {
    await mkdirPathComponentsWithGuards({ rootReal, targetPath: path.dirname(resolved) });
  }

mkdirPathComponentsWithGuards creates one component at a time, guards each parent across the mutation, and realpath-checks every created segment against the canonical root before descending. A parent swapped to an out-of-root symlink mid-call is rejected instead of followed. In-root symlinked components stay allowed, so the behavior added in #39 is preserved.

Why this is the right boundary

  • The helper is the repository's existing canonical primitive for this operation. This call site was the remaining one in openWritableFileInRoot that did not use it. mkdirPathFallback (src/root-impl.ts:1361) and runPinnedWriteFallback (src/pinned-write.ts:262) already route through it, so this converges the writable-open path onto the same primitive rather than adding a second mechanism.
  • write() and create() on POSIX were already safe: their parent creation goes through mkdirPathComponentsWithGuards or the fd-relative Python helper. This change makes append(), openWritable(), and the copyIn() fallback match them.
  • mkdirPathComponentsWithGuards already returns the resolved real parent, but the existing fs.realpath(resolved) and isPathInside handling below the call site keeps working unchanged for in-root symlinked parents, so no further adjustment was needed and the diff stays at the one call site.
  • One behavior change worth flagging: when the parent path already exists as a regular file, the old code surfaced a raw EEXIST and the new code surfaces FsSafeError("not-file"). That matches what the pinned write path already returns for the same situation, so it makes the two configurations agree rather than diverge.
  • Sibling surface I did not change: writeMissingFileFallback (src/root-impl.ts:1600) has the same raw fs.mkdir(..., { recursive: true }) with no guard around it at all. It is reachable only on Windows (writeFileInRoot routes to writeFileFallback only when process.platform === "win32"), where the equivalent swap would use a directory junction. I have no Windows environment to reproduce or verify it on, so I left it out rather than ship an unverified change on a platform I cannot exercise. Happy to follow up separately, or to fold it in here if you would rather have both in one change.

Verification

  • pnpm lint:file-size and pnpm lint:fs-boundary: clean.
  • pnpm build: clean.
  • pnpm test: 46 files, 501 passed, 4 skipped.
  • New regression case in test/write-boundary-bypass.test.ts fails on pristine main 8cced2a (out-of-root logs directory observed) and passes with this patch.

Real behavior proof

Behavior addressed: root().append() with mkdir enabled creates directories outside the root when a parent component is swapped to an out-of-root symlink during the call, while the caller receives an error and believes the operation was blocked.
Real environment tested: the compiled package (dist/index.js) built from pristine main 8cced2a and from this patch at 498ed24, driven through the public root() API with default options and default FS_SAFE_PYTHON_MODE=auto. Everything is real on-disk state on macOS: a real temporary workspace root, a real sibling victim directory outside it, and a separate forked OS process flipping <root>/data between a plain directory and a symlink to the victim. Nothing is stubbed and there is no external service involved.
Exact steps or command run after this patch: node proof.mjs <dist> <label>, which opens root(<workspace>), forks the attacker process, then calls await r.append("data/logs/2026/07/app.log", "entry\n") in a bounded loop and walks the victim directory outside the root. Identical script, identical inputs, run against each build.
Evidence after fix:

# BEFORE (pristine main 8cced2a)
[BEFORE pristine main 8cced2a] append("data/logs/2026/07/app.log") attempts: 13
[BEFORE pristine main 8cced2a] error surfaced to the caller: not-found: file not found
[BEFORE pristine main 8cced2a] paths present outside the root: ["logs/","logs/2026/","logs/2026/07/"]

# AFTER (this patch 498ed248f9fa902955ed6436cce5faf4dccdac53, identical inputs)
[AFTER this patch] append("data/logs/2026/07/app.log") attempts: 2000
[AFTER this patch] error surfaced to the caller: not-file: directory component must be a directory
[AFTER this patch] paths present outside the root: (none)

Observed result after fix: on pristine main the 13th call left the directory tree logs/2026/07/ outside the root while reporting not-found to the caller; with this patch 2000 calls against the same live swapping process left nothing outside the root and the rejection reason became the accurate not-file: directory component must be a directory.
What was not tested: Windows was not exercised, so the sibling writeMissingFileFallback path noted above is unverified and unchanged. The proof is a timing race, so the "before" run reproduces probabilistically (13 attempts on this run, 5 to 20 typical); the "after" run is bounded at 2000 attempts rather than proven exhaustively. No published package was installed; the compiled output of each tree was loaded directly.

openWritableFileInRoot created the destination's parent directories with a
single recursive fs.mkdir, which follows symlinks in components that already
exist. The only protection was a guard on the nearest existing ancestor, and
adding a symlink child to that ancestor changes neither its dev/ino nor its
realpath, so both guard assertions passed while the mkdir walked out of the
root.

Route the parent creation through mkdirPathComponentsWithGuards, matching
mkdirPathFallback and runPinnedWriteFallback. It creates one component at a
time and realpath-checks each one against the root before descending, so a
parent swapped to an out-of-root symlink during the call is rejected instead
of followed.
@yetval
yetval requested a review from a team as a code owner July 26, 2026 16:28
@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. labels Jul 26, 2026
@clawsweeper

clawsweeper Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codex review: needs maintainer review before merge. Reviewed July 26, 2026, 12:33 PM ET / 16:33 UTC.

ClawSweeper review

What this changes

This PR replaces recursive parent creation for root().append(), root().openWritable(), and the relevant copyIn() fallback with guarded per-component mkdir creation, and adds a parent-symlink-swap regression test.

Merge readiness

⚠️ Ready for maintainer review - 3 items remain

Keep open for maintainer review: the production change reuses the canonical guarded mkdir path and the PR provides credible real macOS race proof, but it also changes the observable parent-file failure from raw EEXIST to FsSafeError("not-file"), which repository policy treats as a compatibility-sensitive error contract.

Likely related people

  • ctbritt — high confidence; introduced the current guarded-directory behavior that this PR relies on.

Priority: P1
Reviewed head: 498ed248f9fa902955ed6436cce5faf4dccdac53
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A narrow, well-explained security fix with strong real behavior evidence; the remaining merge question is the intentional public error-contract change.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR body includes a before-and-after compiled-package live filesystem race run through the public API, with observed outside-root artifacts on current main and none after the patch; it also states the untested Windows limitation.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR body includes a before-and-after compiled-package live filesystem race run through the public API, with observed outside-root artifacts on current main and none after the patch; it also states the untested Windows limitation.
Evidence reviewed 5 items Current vulnerable call path: Current main uses a single recursive mkdir inside openWritableFileInRoot; recursive Node mkdir can follow an already-created symlink component during the parent-swap window described by this PR.
Proposed boundary fix: The PR replaces the recursive mkdir plus nearest-ancestor guard with mkdirPathComponentsWithGuards, the repository's existing per-component guard and realpath-check primitive.
Focused regression coverage: The added non-Windows test repeatedly races append() against a parent symlink swap and asserts that the outside directory remains unchanged after every attempt.
Findings None None.
Security None None.

How this fits together

The root write subsystem accepts an untrusted root-relative destination path and prepares its parent directories before opening a writable file. That directory-preparation step feeds the final file open and must preserve confinement even while another same-user process changes path components.

flowchart LR
  A[Untrusted relative path] --> B[Root path resolution]
  B --> C[Alias escape check]
  C --> D[Guarded parent creation]
  D --> E[Real-path boundary check]
  E --> F[Writable file handle]
  G[Concurrent parent swap] --> D
Loading

Decision needed

Question Recommendation
Should writable-open operations normalize a regular-file parent failure from raw EEXIST to FsSafeError("not-file") while adopting the guarded mkdir helper? Accept normalized fs-safe error: Merge the guarded helper change and make not-file the consistent error for a regular-file parent, matching the pinned-write path.

Why: The security route is narrow and matches an existing primitive, but repository policy identifies public error codes as compatibility surfaces and does not establish that raw Node error behavior may be changed without maintainer intent.

Before merge

  • Resolve merge risk (P1) - Existing callers that distinguish raw EEXIST from fs-safe error codes will observe not-file when a writable-open parent component is a regular file; the PR documents the normalization, but the public error-contract compatibility choice should be explicit before merge.
  • Complete next step (P2) - The code repair is concrete, but a maintainer must decide whether the documented error-code change is acceptable before this security-sensitive public contract can land.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Changed surface 3 files affected; 35 added, 4 removed The patch is narrowly scoped to one production call site, one focused regression test, and one release note.
Production behavior 1 recursive mkdir route replaced The security change converges writable-open parent creation on the existing guarded primitive rather than introducing a parallel implementation.

Merge-risk options

Maintainer options:

  1. Land with the normalized error contract (recommended)
    Accept the documented not-file result for a regular-file parent because it aligns writable-open behavior with the existing guarded pinned-write path.
  2. Preserve the legacy error surface
    Pause for a narrow wrapper or mapping if compatibility with callers that inspect raw EEXIST is required.

Technical review

Best possible solution:

Keep the guarded per-component mkdir route, retain the race regression test, and merge only after explicitly accepting the normalized not-file error as the writable-open contract for a regular-file parent.

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

Yes. The PR supplies a concrete current-main macOS reproduction through the public compiled root().append() API, using a live separate process to swap a parent directory for an outside-root symlink and observing directories outside the root.

Is this the best way to solve the issue?

Unclear. Reusing the existing guarded per-component mkdir helper is the narrowest maintainable security fix, but maintainers must first confirm that its not-file error normalization is acceptable for the writable-open public contract.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 8cced2ab6264.

Labels

Label changes:

  • add P1: This fixes a confirmed same-user race that can create directories outside a configured filesystem root while returning an error to the caller.
  • add merge-risk: 🚨 compatibility: The guarded helper changes the observable regular-file-parent failure from raw EEXIST to the public fs-safe not-file error.
  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes a before-and-after compiled-package live filesystem race run through the public API, with observed outside-root artifacts on current main and none after the patch; it also states the untested Windows limitation.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body includes a before-and-after compiled-package live filesystem race run through the public API, with observed outside-root artifacts on current main and none after the patch; it also states the untested Windows limitation.

Label justifications:

  • P1: This fixes a confirmed same-user race that can create directories outside a configured filesystem root while returning an error to the caller.
  • merge-risk: 🚨 compatibility: The guarded helper changes the observable regular-file-parent failure from raw EEXIST to the public fs-safe not-file error.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body includes a before-and-after compiled-package live filesystem race run through the public API, with observed outside-root artifacts on current main and none after the patch; it also states the untested Windows limitation.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes a before-and-after compiled-package live filesystem race run through the public API, with observed outside-root artifacts on current main and none after the patch; it also states the untested Windows limitation.

Evidence

What I checked:

  • Current vulnerable call path: Current main uses a single recursive mkdir inside openWritableFileInRoot; recursive Node mkdir can follow an already-created symlink component during the parent-swap window described by this PR. (src/root-impl.ts:825, 8cced2ab6264)
  • Proposed boundary fix: The PR replaces the recursive mkdir plus nearest-ancestor guard with mkdirPathComponentsWithGuards, the repository's existing per-component guard and realpath-check primitive. (src/root-impl.ts:825, 498ed248f9fa)
  • Focused regression coverage: The added non-Windows test repeatedly races append() against a parent symlink swap and asserts that the outside directory remains unchanged after every attempt. (test/write-boundary-bypass.test.ts:260, 498ed248f9fa)
  • Helper provenance: The related merged change established that guarded component creation must allow symlinked directories that resolve inside the root; this PR routes the writable-open path through that established behavior. (src/guarded-mkdir.ts:60, 0c4ede06ecb0)
  • Real behavior proof: The PR body reports a public-API, compiled-package macOS comparison: pristine main left logs/2026/07/ outside the root after a live parent swap, while the patch completed 2,000 bounded attempts without outside directories. (test/write-boundary-bypass.test.ts:260, 498ed248f9fa)

Likely related people:

  • ctbritt: Authored the merged guarded-mkdir change that defines the in-root symlink behavior this PR intentionally preserves while extending its use to writable opens. (role: guarded-directory feature owner; confidence: high; commits: 0c4ede06ecb0, 8cced2ab6264; files: src/guarded-mkdir.ts, test/guarded-mkdir-symlink-parent.test.ts)

Rank-up moves

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

  • Obtain maintainer confirmation that normalizing a regular-file parent failure to not-file is the intended writable-open compatibility contract.

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.

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. P1 Urgent regression or broken agent/channel workflow affecting real users now. 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant