test(client): Agent Skills — root-swap races the descriptor walk does not close (SEC-8985 row 2) - #68
Draft
pkaeding wants to merge 2 commits into
Draft
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>
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>
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.
What
Three failing tests,
TestRootSwapRacesinpackages/client/tests/test_skills_fs.py, stacked on #66. They state the contract the SDR response gives for SEC-8985 row 2 (every destructive operation runs relative to a descriptor "held for the duration of the reconcile") and show the code does not meet it yet. No implementation change in this PR.The gap
_resolve_rootvalidates the root once and returns aPath. Nothing holds it open. Each write and each prune then opens<root>/<key>by path withO_NOFOLLOW | O_DIRECTORYand pins that directory.O_NOFOLLOWguards only the final component, so the root and every ancestor are re-resolved on every such open. Swap the root for a symlink after validation and the open follows it: the descriptor is pinned to<outside>/<key>, and every descriptor-relative step behind it runs against the wrong directory.atomic_write_indoes refuse the manifest rewrite afterwards (the run reports an error), but by then the skill file is already outside the root.The existing
TestSymlinkAttacksrace tests swap<root>/<key>and prove that window is closed. These swap<root>itself.Precondition: write permission on the root's parent, not on the root. The README privilege-separation checklist denies the agent identity the root, the skill directories, the files and the manifest, and says nothing about the parent. In the documented layout (
<app>/.claude/skills) the parent is.claude, which the agent identity typically owns.What the tests show
os.mkdir(<root>/a)SKILL.mdwritten into<outside>/a/O_NOFOLLOWopen of<root>/a<outside>/a/SKILL.md, never in the manifest, replaced with served contentO_NOFOLLOWopen of<root>/a<outside>/a/SKILL.mdunlinked, then<outside>/aremoved by thermdiruv run pytest packages/client/tests/test_skills_fs.py -k TestRootSwapRaces:The swap is fired from an
os.mkdir/os.openinterceptor (_SwapRootDuring, the sibling of the existing_SwapDirectoryDuring), so it lands at the narrowest possible instant rather than by timing. Nothing in the implementation is touched.Why the build is red
Deliberately. The tests assert the contract, the code violates it, and the failing run is the demonstration. They go green when the fix below lands, with no marker to remove. Rest of the suite on this branch: 727 passed, 3 failed (these three);
ruff checkandruff format --checkclean.The fix (not in this PR)
Pin the root once at the top of
write_skillsand walk from it:root_fd = os.open(root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW), thenos.mkdir(key, dir_fd=root_fd)andos.open(key, O_DIRECTORY | O_NOFOLLOW, dir_fd=root_fd)for each skill directory, andatomic_write(..., dir_fd=root_fd)for the manifest.os.open,os.mkdir,os.stat,os.unlinkandos.replaceall takedir_fdon POSIX andsafe_fs.SUPPORTS_DIR_FDalready probes for them. If the fix is not taken, the minimum is adding the root's parent to the README verification checklist and changing the "held for the whole reconcile" sentence in the response and the design-doc comment to "the skill directory is pinned per operation".Companion PR with the same three tests for the TypeScript SDK: launchdarkly/js-ai-sdk#49.
🤖 Generated with Claude Code
via LD Research 🤖