fix(client): pin the skills root through /proc/self/fd on Linux (SEC-8985 row 2) - #51
Merged
Merged
Conversation
…es not close Three expected-failure tests (skills-fs-root-swap.test.ts) for SEC-8985 row 2. resolveRoot validates the root once and returns a path; nothing holds it open. Each write and prune re-opens <root>/<key> by path with O_NOFOLLOW, which guards only the final component, so a root swapped for a symlink after validation redirects the open into the attacker's directory. assertUnswapped does not notice, because it lstat's the same swapped path the handle came from. Precondition is write permission on the root's parent, which the README privilege-separation checklist does not mention, so the documented mitigation for Node's residual exposure does not cover this. The tests state the contract (nothing lands outside the root, no outside file is overwritten, no outside file is removed) under a knownGap() wrapper: pytest's strict xfail narrowed to assertion errors, which it.fails is not, so a harness breakage is a real failure and the wrapper fails the run the day the body passes. Separate file because the swap must land before the per-skill directory is opened, which means intercepting mkdir/open from node:fs/promises with a file-wide vi.mock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 2b9025e)
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 31d47db)
SEC-8985 row 2. `resolveRoot` validated the managed root and returned a path; nothing held it open. Every write and prune then opened `<root>/<key>` by path with `O_NOFOLLOW`, which guards only the final component, so the root was re-resolved on each one and a root swapped for a symlink after validation redirected the open into the attacker's directory. `assertUnswapped` agreed with itself, because it lstat'd the same swapped path the handle came from. Only the manifest write pinned the root, and by then the skill files were already outside it. The precondition is write access to the root's *parent* — `.claude` for a root of `.claude/skills`, which the agent identity typically owns. The root is now opened `O_RDONLY|O_DIRECTORY|O_NOFOLLOW` once, fstat'd, and held for the whole reconcile, and every child is addressed as `/proc/self/fd/<fd>/<name>`. The kernel resolves that prefix to the pinned inode rather than to the name, which gives it the property an `*at()` call would have; the per-skill directory is pinned in turn for the files inside it. Gated on a new `SUPPORTS_PROC_FD` probe (Linux plus a usable `/proc/self/fd`, verified at load by resolving a live descriptor back to its own inode), so macOS and Windows keep the per-component lstat floor unchanged — `assertUnswapped` is skipped only for a directory the caller addressed through a descriptor, which is why the floor's own tests still exercise it on Linux. Containment checks in `unsafePathReason` stay as defense in depth; they are no longer the boundary here. A swap landing before the pin fails the open and is reported as a run-level error rather than thrown, since the root was usable when the caller named it. `atomicWriteIn` is gone: the root is always pinned by the caller now, so a primitive whose job was to re-open a directory by path is exactly the footgun this bug was made of. Descriptor addresses are stripped out of `errno` messages, which leaves the offending file named relative to whichever directory was pinned. Tests: the two `<root>/<key>` races in skills-fs.test.ts now run wherever either capability probe is true, so they are live on Linux. The root-swap file from Security's PR #49 keeps its assertions; its trigger matches the final component plus the shape of its parent rather than one exact path, so the same bodies fire the swap against both the old and new addressing — pinning the trigger to `path.join(root, key)` would have made them pass vacuously the moment the fix landed. Verified in a Linux container: all four fail against the pre-fix code with real assertion failures (the write escapes, the outside victim is clobbered, the outside victim is unlinked) and pass against this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
force-pushed
the
xie/skills-08-review-closeout
branch
from
September 14, 2026 20:13
90b45a5 to
888a616
Compare
XieX
force-pushed
the
xie/skills-09-root-pin
branch
from
September 14, 2026 20:13
5305455 to
35f3779
Compare
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the root-swap window in the Agent Skills filesystem layer.
-xso authorship is preserved). Please close test(client): Agent Skills — root-swap races the pinned handle does not close (SEC-8985 row 2) #49 as superseded — I have deliberately left it open for you.The first commit is Security's tests and CI was red on it (run — three failures, real assertion failures rather than harness errors). The head of this branch is green.
The gap
resolveRootvalidated the managed root and returned a path; nothing held it open.writeThroughPinnedDirectory,pruneOneandsweepSkillDirectorythen opened<root>/<key>by absolute path withO_NOFOLLOW, which guards only the final component — so the root was re-resolved on every one of those opens, and a root swapped for a symlink after validation redirected the open into the attacker's directory. On the create pathmkdir(<root>/<key>)followed it too.assertUnswappeddid not notice, because it compared the handle against anlstatof the same swapped path the handle came from, so the two agreed. Only the manifest write pinned the root, and by then the skill files were already outside it.The attacker precondition is write access to the root's parent —
.claudefor a root of.claude/skills, which the agent identity typically owns. The README's privilege-separation checklist did not mention ancestors, so the documented mitigation for Node's residual exposure did not cover this.The fix
SUPPORTS_PROC_FDprobe insafe-fs.ts: true on Linux when/proc/self/fdis usable, verified at module load by opening a descriptor and confirming the magic symlink resolves back to that descriptor's own inode. A genuine probe, not a platform string, for the same reasonSUPPORTS_DIR_FDis one — the swap-race tests are gated on it.writeSkillsopens the rootO_RDONLY|O_DIRECTORY|O_NOFOLLOWimmediately afterresolveRoot,fstats it, and holds it for the whole reconcile (closed infinally). Threaded throughwriteAll,pruneEntries,rewriteManifest,sweepOrphanTemps,loadManifestand the per-skill helpers as aPinnedDirectory, which carries both the real path (for reports, manifest keys and containment checks) and the address (for filesystem calls)./proc/self/fd/<fd>/<name>formkdir,open,rename,unlink,rmdir,lstat,readdirand the manifest write. The kernel resolves that prefix to the pinned inode, so a swap of the root's name cannot redirect the operation. The per-skill directory is pinned in turn for the files inside it.assertUnswappedfloor are unchanged.assertUnswappedis skipped only for a directory the caller addressed through a descriptor — driven by how the caller addressed it rather than by a global flag, which is what keeps the floor's own tests insafe-fs.test.tsexercising it on Linux too.unsafePathReasoncontainment checks stay as defense in depth.realpathcollapses a descriptor address to the real location, so the comparison reads identically on both paths.A swap that lands before the pin fails the open and is reported as a run-level
erroraction rather than thrown:resolveRootthrows because an unusable root is a caller mistake, whereas a root that was a real directory an instant ago is an attack in flight.Beyond the brief
Two things I did not find in the ticket but that follow from it:
atomicWriteInremoved. It existed to re-open the root by path for the manifest write; with the root pinned for the whole reconcile that is both redundant and exactly the footgun this bug was made of.rewriteManifestnow writes through the held handle. Its one direct test went with it — the property it covered (a symlinked root is refused) is now covered by the new root-pin refusal test.errnomessages. Otherwise an operator readsopen '/proc/self/fd/23/a/SKILL.md'. Stripping the prefix leaves the file named relative to whichever directory was pinned, which is the form the rest of the messages already use.Tests
Security's assertions are unchanged. Their trigger was not, and this matters: the hook fired the swap only when the intercepted
mkdir/openargument strictly equalledpath.join(root, 'a'). After the fix the code passes/proc/self/fd/N/a, so the trigger would never have matched and all three tests would have passed vacuously. The trigger now matches on the final component plus the shape of its parent — the root's own path or a descriptor prefix — so one test body fires the swap against either implementation, which is the only thing that makes the red-to-green transition evidence of anything.describe.skipIf(!SUPPORTS_PROC_FD): runs on Linux CI, skipped on macOS dev machines.<root>/<key>races inskills-fs.test.tsnow run when either capability probe is true, so they are live on Linux for the first time.SUPPORTS_PROC_FDreflects the real property (never true off Linux; on Linux only when the descriptor resolves to its own inode).interceptRename/interceptUnlinkresolve descriptor addresses back to real paths at interception time, while the descriptor is still open — four existing tests assert on where an operation landed.Verified against the pre-fix code in a Linux container (
docker run --rm -v "$PWD":/w node:24), with both source files reverted to the base and only the probe shimmed in: all four fail with real assertion failures — the write escapes intooutside, the outsideSKILL.mdis clobbered with served content, the outside victim is unlinked. All four pass against this branch.Local:
yarn testgreen (652 client tests; 646 + 6 skipped on macOS, all 652 on Linux),biome checkclean,yarn typecheckclean,sherifclean.Docs
packages/client/README.md: the privilege-separation checklist and its shell snippet now cover the root's parent and every ancestor (the snippet walks up to/), and the Security posture paragraph no longer claims the Python SDK is alone in holding a descriptor for the whole reconcile or that Node's floor runs on every platform. It now splits by platform: Linux holds the root handle and addresses children through/proc/self/fd; macOS and Windows keep the per-component lstat floor, where write permission on the root and now its ancestors is the security boundary.Not in this PR
The companion Python fix is being done separately in
python-ai-sdk.🤖 Generated with Claude Code
Note
Overview
Closes a TOCTOU hole where swapping the managed skills root for a symlink after
resolveRootcould redirectwriteSkillswrites and prunes outside the intended directory (attackers need write access on the root’s parent, e.g..claudefor.claude/skills).Linux:
writeSkillsnow opens and holds the managed root for the entire reconcile and routes child operations through/proc/self/fd/<fd>/<name>via a newSUPPORTS_PROC_FDprobe anddirectoryAddress/PinnedDirectory(real paths for reports, descriptor addresses for syscalls).atomicWriteInis removed; the manifest write uses the pinned root.assertUnswappedis skipped when paths are already descriptor-relative.macOS / Windows: Behavior stays on the per-component
lstatfloor; README now documents that the security boundary includes ancestor directories, with an expanded privilege-separation checklist and shell snippet.Tests: New Linux-gated root-swap race suite; existing swap-race and intercept tests updated so triggers match both path and proc-fd spellings and assertions still resolve real paths.
Reviewed by Cursor Bugbot for commit 35f3779. Bugbot is set up for automated code reviews on this repo. Configure here.