test(client): Agent Skills — the filesystem abuse matrix (5/5) - #54
Open
XieX wants to merge 2 commits into
Open
Conversation
XieX
added a commit
that referenced
this pull request
Aug 26, 2026
``test_key_at_the_data_model_bound_is_reported_not_raised`` read ``dst_dir_id`` directly, but that field is only populated when ``os.replace`` is called with ``dir_fd`` kwargs. On the path fallback (``SUPPORTS_DIR_FD`` false — the shape Windows takes) it stays ``None``, so the assertion failed even though the valid skill had been renamed correctly into its own directory. ``_assert_atomic_rename_of`` already branches on both call shapes and asserts the same containment property, plus the single-rename count the list comparison implied. Use it. Verified by forcing the probe off for a whole session: this was the only test in the module that broke under the no-``*at()`` shape, and the helper-based check passes under both. Reported by Cursor Bugbot on #54. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
donei003
approved these changes
Aug 27, 2026
knfreemLD
approved these changes
Aug 28, 2026
XieX
force-pushed
the
split/skills-materialization
branch
from
August 28, 2026 18:03
b0d9e87 to
3709086
Compare
XieX
added a commit
that referenced
this pull request
Aug 28, 2026
``test_key_at_the_data_model_bound_is_reported_not_raised`` read ``dst_dir_id`` directly, but that field is only populated when ``os.replace`` is called with ``dir_fd`` kwargs. On the path fallback (``SUPPORTS_DIR_FD`` false — the shape Windows takes) it stays ``None``, so the assertion failed even though the valid skill had been renamed correctly into its own directory. ``_assert_atomic_rename_of`` already branches on both call shapes and asserts the same containment property, plus the single-rename count the list comparison implied. Use it. Verified by forcing the probe off for a whole session: this was the only test in the module that broke under the no-``*at()`` shape, and the helper-based check passes under both. Reported by Cursor Bugbot on #54. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
force-pushed
the
split/skills-fs-hardening
branch
from
August 28, 2026 18:06
13a851c to
e87c5f9
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 e87c5f9. Configure here.
Fifth and last of five slices. The adversary. Every filesystem defense the previous two slices introduced now has a test that fails if the defense is removed, plus the materialization telemetry allowlist. - **Path traversal.** A key that escapes the root, a key that is the manifest filename, an over-long key, and a key that resolves outside after `realpath` — each refused before any filesystem call, and the resolved containment check asserted on inode identity rather than on path strings. - **Symlink attacks.** A symlinked skill directory, a symlinked target file, and the `<root>/<key>` directory swapped for a symlink at the exact instant of the rename and of the unlink — the narrowest version of the window the descriptor pinning exists to close, fired from the interception point rather than from implementation internals. - **The no-`*at()` shape.** The path fallback Windows takes for every operation, exercised with the capability probe forced off, so the platform that cannot pin a descriptor is not the untested one. The TOCTOU tests skip off that same flag deliberately: a probe that wrongly reported "unsupported" cannot also silently skip the tests that would have caught it. - **Non-regular files and clobber protection.** A fifo or a directory where `SKILL.md` belongs, and a file at a managed path with no matching manifest entry — reported and left alone, never overwritten and never removed. - **Corrupt manifests.** Unreadable, unparseable, not an object, malformed entries, and a `manifestVersion` this release cannot read: no overwrites, no prunes, an error action naming the manifest, and the manifest itself left as it was found. - **Atomicity.** A crash injected between the write and the rename leaves neither a partial file nor a temp file, and the one recorded rename is proved to have moved `SKILL.md` within the target's own directory — by descriptor identity where the platform has `renameat`, which also rules out the descriptor having been redirected between the check and the rename. - **Telemetry.** The three signal names are asserted as an allowlist rather than a floor: any other name reaching the emitter fails, the two deliberately excluded names are called out by name, no signal carries a filesystem path or the skill body, an emitter that raises never fails the reconcile, and `client.track()` is never reached. Two of these are worth naming, because the obvious test does not reach the guard. The unencodable-content cases pin `contentHash` to the sha256 of the bytes a non-strict encoder would have fabricated, since an arbitrary wrong hash is rejected by the mismatch check first and never exercises the encoder guard. The redaction cases smuggle the body through `contentHash` and through `key`, since a sweep using a well-formed digest under a valid key reaches neither replacement branch. Testing: `uv run pytest` → 1280 passed. `ruff check`, `ruff format --check`, and `mypy packages/*/src` all clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
``test_key_at_the_data_model_bound_is_reported_not_raised`` read ``dst_dir_id`` directly, but that field is only populated when ``os.replace`` is called with ``dir_fd`` kwargs. On the path fallback (``SUPPORTS_DIR_FD`` false — the shape Windows takes) it stays ``None``, so the assertion failed even though the valid skill had been renamed correctly into its own directory. ``_assert_atomic_rename_of`` already branches on both call shapes and asserts the same containment property, plus the single-rename count the list comparison implied. Use it. Verified by forcing the probe off for a whole session: this was the only test in the module that broke under the no-``*at()`` shape, and the helper-based check passes under both. Reported by Cursor Bugbot on #54. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
force-pushed
the
split/skills-fs-hardening
branch
from
August 28, 2026 20:29
e87c5f9 to
c88d5bf
Compare
XieX
force-pushed
the
split/skills-materialization
branch
from
August 28, 2026 20:29
3709086 to
568729d
Compare
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.

PR 5 of 5 splitting draft #45 for review. Stacked on #53. This is the tip of the stack — its tree is #45's, plus the changes for review findings 1, 2 and 3 and the new
safe_fsunit tests.main← #50 ← #51 ← #52 ← #53 ←split/skills-fs-hardeningNote
Aug 28: rebased in place for the stack-wide bytes pivot (
Skill.content: bytes,frontmatter()deleted). Test helpers here constructSkillvalues from encoded bytes now; the defenses under test are unchanged. This branch also carries the rename-containment assertion fix cherry-picked across the rebase.What's here
Tests only, no source. The adversary: every filesystem defense PRs 3 and 4 introduced now has a test that fails if the defense is removed.
realpath. Each refused before any filesystem call, and the containment check asserted on inode identity rather than on path strings.<root>/<key>swapped for a symlink at the exact instant of the rename and of the unlink. That last one is the narrowest possible version of the window descriptor pinning exists to close, fired from the interception point (os.replace/os.unlink) rather than from implementation internals.*at()shape — the path fallback Windows takes for every operation, exercised with the capability probe forced off, so the platform that cannot pin a descriptor is not the untested one. The TOCTOU tests skip off that same flag deliberately: a probe that wrongly reported "unsupported" must not also silently skip the tests that would have caught it.SKILL.mdbelongs, and a file at a managed path with no matching manifest entry: reported and left alone, never overwritten and never removed.manifestVersionthis release cannot read. No overwrites, no prunes, anerroraction naming the manifest, and the manifest itself left as it was found.SKILL.mdwithin the target's own directory. Where the platform hasrenameatthat is asserted by descriptor identity (src_dir_fd == dst_dir_fd, resolving to the skill directory's(st_dev, st_ino)), which is stronger than comparing path strings because it also rules out the descriptor having been redirected between the check and the rename.client.track()is never reached.Notes for reviewers
Two cases are worth naming, because the obvious test does not reach the guard:
contentHashto the sha256 of the bytes a non-strict encoder would have fabricated. An arbitrary wrong hash is rejected by the mismatch check first and never exercises the encoder guard at all, so a naive test here passes against an implementation that reaches forerrors="surrogatepass".contentHashand throughkey. A sweep using a well-formed digest under a valid key reaches neither replacement branch.The set of tests in
test_skills_fs.pyat this tip matches #45's modulo the bytes pivot (helpers buildSkill.contentasbytes; the deletedfrontmatter()tests were removed in PR 1) — nothing else was dropped in the split. Two classes are reordered, which is why the file shows more diff churn than the content change warrants.Testing
uv run pytest→ 1280 passed.ruff check,ruff format --check, andmypy packages/*/srcall clean.🤖 Generated with Claude Code
Note
Overview
Tests only — expands
test_skills_fs.pyso every filesystem hardening behavior from earlier stack PRs has a regression test in one place (no implementation changes).The module gains interception helpers (
_ReplaceSpy,_SwapDirectoryDuring, inode-based_assert_atomic_rename_of) to assert SDK validation blocks writes beforeos.replace, and to simulate rename/unlink races and injected rename failures.New coverage includes atomicity (0644, single in-directory rename, no partial files on failure), a parametrized path traversal matrix (hostile keys must not attempt
SKILL.mdrenames), symlink refusal and descriptor-pinned TOCTOU cases (skipped whenSUPPORTS_DIR_FDis false), a forced no-*at()Windows-shaped fallback, FIFO/non-regular paths, clobber protection, corrupt manifest fail-closed behavior, and telemetry allowlists (materialized/revoked/integrity only, no paths or bodies, manifest version redaction, integrity property parity across accessor vs write paths).Reviewed by Cursor Bugbot for commit c88d5bf. Bugbot is set up for automated code reviews on this repo. Configure here.