fix(client): pin the skills root for the whole reconcile (SEC-8985 row 2) - #70
Conversation
…does not close Three expected-failure tests (TestRootSwapRaces) for SEC-8985 row 2. The SDR response says every destructive operation runs relative to a descriptor held for the duration of the reconcile. The code pins <root>/<key> per operation and never holds the root: _resolve_root validates it once and returns a path, and each write and prune re-opens <root>/<key> by path with O_NOFOLLOW, which guards only the final component. A root swapped for a symlink after validation redirects the open, and every descriptor-relative step behind it, into the attacker's directory. Precondition is write permission on the root's parent, which the README checklist does not mention. The tests state the contract (nothing lands outside the root, no outside file is overwritten, no outside file is removed) and are marked xfail(strict=True, raises=AssertionError) so the suite stays green, the gap is recorded next to the other race tests, and the fix cannot land without removing the marker. Run with --runxfail to see the three escapes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit c22688b)
The expected-failure marker made the suite green while the contract was violated. A red run is the demonstration: the tests assert the contract, the code does not meet it, and they go green when the root is pinned for the reconcile, with nothing to remove. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 37ddb6a)
SEC-8985 row 2. The SDR response says every destructive operation runs relative to a descriptor held for the duration of the reconcile. It did not. `_resolve_root` validated the root and returned a plain `Path`; nothing held it open. Each write and each prune then opened `<root>/<key>` by path with `O_NOFOLLOW|O_DIRECTORY` and pinned that — and `O_NOFOLLOW` guards only the final component, so the root and every ancestor were re-resolved on every such open. A root renamed aside and replaced with a symlink after validation redirected the open, and with it every descriptor-relative step behind it, into the attacker's directory; on the create path `os.mkdir(<root>/<key>)` followed the link as well, and `mkdir` follows a symlink at its parent. The manifest write was the one operation that pinned the root, and it ran last, by which time the skill files were already outside it. The attacker precondition is write access to the root's *parent* — `.claude` for a root of `.claude/skills` — which the README checklist did not mention. `write_skills` now opens the root once, immediately after `_resolve_root`, with `O_RDONLY|O_DIRECTORY|O_NOFOLLOW`, confirms `S_ISDIR` on the descriptor, and holds it until the call returns. The descriptor is threaded through `_write_all`, `_prune`, `_rewrite_manifest`, the orphan sweep and the per-skill helpers, and every destructive step names a bare component against it: `os.mkdir(key, dir_fd=root_fd)`, `os.open(key, ..., dir_fd=root_fd)` for the skill directory, `os.rmdir(key, dir_fd=root_fd)`, and `atomic_write(..., dir_fd=root_fd)` for the manifest. A root swapped in the one interval left — after validation, before the open — fails `O_NOFOLLOW` and is reported as a run-level error with nothing touched, rather than as the `ValueError` an unusable root raises. `safe_fs`'s three openers take a `dir_fd` for the *parent* rather than growing a parallel API, and `SUPPORTS_DIR_FD` now probes `os.mkdir` and `os.rmdir` alongside the four it already named. Where the `*at()` family is absent the per-component `lstat` floor runs exactly as before: the root open returns `None` there, and every call site keeps its full-path branch. `_unsafe_path_reason` stays and still runs, but it is documented as defense in depth rather than the boundary — every check in it inspects a path, so each is a check-then-use against anything that can rename a component of that path. Security's three tests fired only when the intercepted `os.mkdir`/`os.open` was handed the absolute `<root>/<key>`, which the fix stops passing — they would have gone green while asserting nothing. The trigger now matches the bare key as well, so the swap fires in both worlds and the tests fail before the fix and pass after it. Two tests added: the root swapped before the pin is refused at the run level, and an audit that across a full reconcile no destructive call names an absolute path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The privilege-separation checklist denied the agent identity the managed root, the per-skill directories, the files and the manifest, and said nothing about the root's parent. That was the precondition for the SEC-8985 row 2 root swap: renaming any ancestor is what lets the root be replaced with a symlink, and in the documented `<app>/.claude/skills` layout the parent is `.claude`, which an agent identity is otherwise likely to own outright. The checklist and its shell snippet now walk every ancestor up to `/`. Write access to one of them is a strictly larger capability than racing the reconcile — no timing is involved, it persists until someone notices, and descriptor pinning inside `write_skills` cannot address it, because the substituted tree is what the agent reads rather than what the SDK wrote. The platform-bound paragraph claimed a descriptor held for the whole reconcile before that was true of the root; it now describes what is actually held and for how long, and names the root's ancestors alongside the root as the security boundary on every platform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5040942. Configure here.
| # Sweep before writing rather than after, so a temp file this run is about | ||
| # to create can never be a candidate. | ||
| _sweep_orphan_temp_files(root, key) | ||
| _sweep_orphan_temp_files(root, root_fd, key) |
There was a problem hiding this comment.
Path probes authorize descriptor writes
High Severity
target.exists() and _read_regular_file still resolve the path after the root is pinned. A swap between _unsafe_path_reason and those probes — the sweep's open, which the new races already fire — can hide a real unmanaged file so the write proceeds through root_fd and overwrites it, or adopt the attacker's bytes and skip updating the real file.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 5040942. Configure here.
There was a problem hiding this comment.
Confirmed as accurate, and deliberately left in place. Tracking it with Security rather than folding it into this PR.
Both outcomes are real and I verified both in the code: exists() returning false through a swap skips the "refusing to overwrite a file this SDK did not write" guard entirely, and the adoption branch matching the attacker's bytes calls _update_entry and skips the write, so the real file is recorded as managed without ever being verified.
Two things bound it, which is why it is not blocking this change. Neither outcome escapes the root — every write still lands through root_fd inside the validated directory. And the attacker has write access to the root's parent, not inside the root, so they cannot choose what the real file contains; the adoption case leaves a stale file reported as current rather than injecting content.
The reason it is not fixed here is shape, not severity. target.exists() and _read_regular_file name <key>/SKILL.md, so covering them the way the manifest read is now covered needs a descriptor for the skill directory, not for the root — which means threading a per-key descriptor through _write_one and its probes. That is a larger change than SEC-8985 row 2 asks for, and it deserves its own review rather than riding along with this one.
Your sibling finding on the manifest read is fixed in d3a8bc1 — that one was not separable, because the manifest is the input that authorizes the destructive steps rather than an ordinary read under the root.
The reconcile pinned the root for every destructive step but still read the manifest by path, and the manifest is the input that authorizes those steps. A root swapped after the pin therefore supplied the entries for the whole run, and the consequences landed in the real root through the descriptor. The damaging case is delayed rather than immediate. `_unsafe_path_reason` refuses a write or a prune whose path resolves out of the validated root, so the swapped run itself reports refusals and deletes nothing. What it cannot refuse is the record: `_rewrite_manifest` commits the entries that were read back over the real manifest, so the run ends with the manifest claiming a file the SDK never wrote — and the *next* reconcile, an ordinary one with no attacker present and every path check passing, removes the customer's file. This is also the one respect in which pinning the root made things worse. Before the pin, the manifest write was the only operation that took its own `O_NOFOLLOW` descriptor, so a swapped root made that write fail and left the real manifest intact. `_load_manifest` now reads through the held descriptor, reusing `_read_regular_file` so the manifest gets the same open the skill files get: `O_NOFOLLOW` refuses a symlink wearing its name, `O_NONBLOCK` plus the `S_ISREG` check on the descriptor refuse a FIFO instead of blocking the event loop on it, and the single open replaces an `exists()`-then-read pair on the same path. `max_bytes=None` reads to EOF, since the manifest is parsed rather than compared against a known length. Reads under the root on the write path are unchanged and still noted as out of scope: `_write_one`'s comparison read names `<key>/SKILL.md`, so covering it needs a descriptor for the skill directory rather than for the root. Two tests, both verified to fail with the source change reverted: - a root swapped between the pin and the manifest read cannot supply the entries — the real manifest still records what it owned; - the same swap cannot poison the ownership record — asserted across both phases, because the deletion happens on the following clean run. Reported by Cursor Bugbot on #70. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>


Fixes SEC-8985 row 2. Supersedes #68, whose two test commits are the first two commits here (cherry-picked with
-x, authorship preserved). SDR response: https://launchdarkly.atlassian.net/wiki/spaces/PD/pages/5293965360The first commit is Security's tests and CI ran red on it (run 33909812977 — 3 failed, 1484 passed, with lint, format and type check green). The head of this branch is green.
The gap
The SDR response says every destructive operation runs relative to a descriptor held for the duration of the reconcile. It did not.
_resolve_rootvalidated the managed root and returned a plainPath— nothing held it open. Each write and each prune then opened<root>/<key>by path withO_NOFOLLOW|O_DIRECTORYand pinned that.O_NOFOLLOWguards only the final component, so the root and every ancestor were re-resolved on every such open: a root renamed aside and replaced with a symlink after validation redirected the open, and with it every descriptor-relative step behind it, into the attacker's directory. On the create pathos.mkdir(<root>/<key>)followed the link too —mkdirfollows a symlink at its parent._sweep_orphan_temp_filesand_prune_one'srmdirhad the same shape. The manifest write was the only operation that pinned the root, and it ran last, by which time the skill files were already outside it.Attacker precondition: write access to the root's parent (
.claude, for a root of.claude/skills) — which the README checklist did not mention. Reproduced: the prune case deleted a file outside the root outright.The fix
write_skillsopens the root once, immediately after_resolve_root, withO_RDONLY|O_DIRECTORY|O_NOFOLLOW, confirmsS_ISDIRon the descriptor, and holds it until the call returns (closed infinally). The descriptor is threaded through_write_all,_prune,_rewrite_manifest, the orphan sweep and the per-skill helpers, and every destructive step names a bare component against it:os.mkdir(root / key)os.mkdir(key, dir_fd=root_fd)os.open(root / key, ...)os.open(key, ..., dir_fd=root_fd)skill_dir.rmdir()os.rmdir(key, dir_fd=root_fd)atomic_write_in(root, MANIFEST, ...)atomic_write(root, MANIFEST, ..., dir_fd=root_fd)A root swapped in the one interval left — after validation, before the open — fails
O_NOFOLLOWand is reported as a run-level error with nothing touched, rather than as theValueErroran unusable root raises.safe_fs's three openers (pinned_directory/open_or_create_directory/open_directory_nofollow) take adir_fdfor the parent rather than growing a parallel API, andSUPPORTS_DIR_FDnow probesos.mkdirandos.rmdiralongside the four it already named. Where the*at()family is absent the per-componentlstatfloor runs exactly as before — the root open returnsNonethere and every call site keeps its full-path branch, whichTestWithoutDirFdcovers._unsafe_path_reasonstays and still runs, now documented as defense in depth rather than the boundary: every check in it inspects a path, so each is a check-then-use against anything that can rename a component of that path.On the tests
Security's
_SwapRootDuringfired only when the interceptedos.mkdir/os.openwas handed the absolute<root>/<key>. The fix stops passing that, so the swap would never have fired and all three tests would have passed vacuously. The trigger now matches the bare key as well, so it fires in both worlds — verified by reverting the source fix locally with the new test code in place, where all five tests fail.Two tests added:
O_NOFOLLOWopen failsELOOP);Also checked by hand: no descriptor leak across 80 reconciles including prunes.
Follow-up: the manifest read (Cursor Bugbot, finding 1)
Bugbot flagged that
_load_manifeststill read by path after the pin, and it was right. Fixed in the last commit, because the manifest is not an ordinary read under the root — it is the input that authorizes every destructive step the pin was added to protect.The damage is delayed, which is what makes it worth fixing rather than noting. During the swapped run nothing is destroyed:
_unsafe_path_reasoncompares against the root path resolved before the swap, sees the write and the prune land outside it, and refuses both, so the report shows only refusals. What it cannot refuse is the record._rewrite_manifestcommits the entries that were read back over the real manifest through the held descriptor, so the run ends with the real manifest claiming a file the SDK never wrote — and the next reconcile, an ordinary one with no attacker present, every path check passing, the key well formed and the path genuinely inside the root, removes the customer's file. Verified end to end: the second run reportsaction=removedfor a file the SDK did not write.This is also the one respect in which pinning the root made things worse, and the reason the original note below understated it. Before the pin, the manifest write was the only operation that took its own
O_NOFOLLOWdescriptor, so a swapped root made that write fail and left the real manifest intact._load_manifestnow reads through the held descriptor, reusing_read_regular_filerather than hand-rolling a second open, so the manifest inherits the protections the skill files already had:path.exists()thenpath.read_text()os.open(MANIFEST, ..., dir_fd=root_fd)O_NOFOLLOWrefuses it as corruptionO_NONBLOCK+S_ISREGon the descriptor refuses it_read_regular_filegaineddir_fd, andmax_bytes=Nonefor a read to EOF — the manifest is parsed rather than compared against a known length, so it has no bound the caller can predict.Two tests, both verified to fail with the source change reverted:
Docs
The privilege-separation checklist and its shell snippet now walk every ancestor of the root up to
/, since renaming any ancestor is what enables the swap. The platform-bound paragraph claimed a descriptor held for the whole reconcile before that was true of the root; it now describes what is actually held, for how long, and names the ancestors alongside the root as the boundary on every platform.Verification
uv run pytest— 1491 passed, 11 skippeduv run ruff check .— cleanuv run ruff format --check .— cleanuv run mypy packages/*/src— cleanNotes
target.exists()and_read_regular_filein_write_one. Bugbot's finding 2 covers these — a swap between_unsafe_path_reasonand those probes can hide a real unmanaged file so the write proceeds throughroot_fd, or match the attacker's bytes so the write is skipped and the file is recorded as managed anyway. Neither escapes the root, and the attacker cannot choose the real file's contents. Covering them properly needs a descriptor for the skill directory rather than for the root, since what they name is<key>/SKILL.md— a larger change than row 2 asks for, so it is flagged here for Security to track rather than folded in.js-ai-sdk.🤖 Generated with Claude Code
Note
Overview
Closes a TOCTOU gap where an attacker with write access to the managed root’s parent could rename the root and leave a symlink, redirecting writes, prunes, and
mkdiroutside the intended tree even after_resolve_rootvalidated the path.write_skillsnow opens and holds a pinned root descriptor for the entire reconcile. Destructive steps (mkdir/open/unlink/rmdirfor skill dirs, manifest atomic write, orphan temp sweep) pass bare path components withdir_fd=root_fdinstead of reopening<root>/…by absolute path. A swap between validation and pin fails with a run-level error and no filesystem changes._load_manifestreads.launchdarkly-skills.jsonthrough the same descriptor so a swapped root cannot supply fake ownership entries.safe_fsgains optional parentdir_fdon directory open/create helpers,_at()for basename vs full path, and expandedSUPPORTS_DIR_FDprobing formkdir/rmdir.README updates the POSIX guarantee text and extends the deployment checklist (plus shell loop) to deny write on every ancestor of the skills root, not just the root and contents.
Tests add root-swap race coverage (
_SwapRootDuringmatching both absolute and bare key paths), pre-pin refusal, manifest-poisoning scenarios, and an audit that destructive syscalls never use absolute paths withoutdir_fd.Reviewed by Cursor Bugbot for commit d3a8bc1. Bugbot is set up for automated code reviews on this repo. Configure here.