Conversation
7f31f9e to
f6c9cd2
Compare
Code review — Agent Skills (discovery, retrieval, materialization)Reviewed against the internal SDK design page (SDK Design: Agent Skills Integration (Python/Node), PD/5215649997) and the delivery-side contract in AIC-2905. Overall this is unusually careful work: the filesystem layer's descriptor-pinned writes, manifest ownership model, prune suppression on incomplete retrieval, and telemetry redaction are all right, and the test suite (163 tests) covers the security surface well. Findings below are ordered by severity. 1. Version pinning cannot be satisfied by the store seam (spec inconsistency, blocking)The spec: "Version selection is by pin (attached) or latest (standalone) … the payload contains every referenced skill in the project plus the newest version of each skill." AIC-2905 confirms delivery: the payload is the union of the latest version of every skill and every version currently referenced by any variation attachment. So multiple versions of the same key coexist in one payload. But the store seam is keyed by key alone: class SkillStore(Protocol):
def get_object(self, kind: str, key: str) -> dict[str, Any] | None: ...and raw = store.get_object(SKILL_OBJECT_KIND, key)
...
if wanted_version is not None and skill.version != wanted_version:
return Resolution(error=...)
Suggested fix: make version part of the lookup identity — either 2.
|
|
Sent off a Devin PR here mostly around correctness and idiomatic code; I think points 1 and 3 we should look to resolve here, and point 2 I believe is a quick enough fix. Everything else I believe is lower priority or quick fixes |
Adds support for Agent Skills: versioned `SKILL.md` documents managed in
LaunchDarkly and attached to AI Config variations by reference. The SDK
surfaces which skills a config references, retrieves their content, and
materializes them onto disk where agent runtimes discover them.
Public API, all exported from the package root:
- `skill_refs(config)` projects a config's `skills` array into
`list[SkillReference]`. Pure — no client, store, or network.
- `get_skill(key, *, version=None)` returns one verified skill, or None.
- `get_skills(refs)` is the batch form, accepting references and bare keys.
- `all_skills()` returns every verified skill the store holds.
- `write_skills(skills, root, *, prune=True, timeout=10.0,
on_unavailable="keep")` materializes under `root`, returning a
`ReconcileReport`.
New types: `Skill`, `SkillReference`, `ReconcileAction`, `ReconcileReport`,
the `ReconcileActionKind` and `OnUnavailable` unions, and the fixed on-disk
/ on-the-wire constants.
Content arrives through an injectable `SkillStore` seam —
`get_object(kind, key)`, `all_objects(kind)`, and an optional
`add_listener(kind, fn)` — configured with
`init_client(options={"skillStore": store})`. `InMemorySkillStore` ships for
local development and testing. The LaunchDarkly delivery transport drops in
behind the same seam with no public API change.
Store data is untrusted. Key and version are revalidated, size is bounded,
and the sha256 of the verbatim UTF-8 bytes must match the delivered
`contentHash`; anything that does not verify is withheld and treated as
missing, so no unverified content is ever returned. Content carrying an
unpaired surrogate has no UTF-8 encoding at all and is withheld too —
`str.encode` is called strictly, never with an error handler that would
fabricate bytes a hash comparison could then accept.
Integrity failures are reported through a private telemetry seam carrying
hashes and byte counts only, never the skill body. The two properties
copied off the wire, `skill_key` and `expected_hash`, are shape-checked and
replaced when malformed, so a hostile store cannot use either one to
publish the body through a signal that is otherwise body-free.
`write_skills` writes `<root>/<key>/SKILL.md` and records what it owns in a
manifest, so it overwrites or removes only files it wrote — a file you
placed yourself is reported and left untouched. Writes are atomic, at mode
0644, and every destructive step runs against a descriptor pinned to a
directory that was already checked, so a path swapped after the check
cannot redirect a write or an unlink out of the managed root. Where the
platform has no `*at()` family (Windows) the identical sequence runs
against full paths. Pruning removes formerly-managed skills that are no
longer referenced, which is how revocation takes effect.
`timeout` bounds retrieval, the writes, and pruning; only the final
manifest rewrite runs past it, so files already written are never orphaned.
`write_skills` performs synchronous filesystem I/O and does not yield — it
is `async` for signature parity with the other accessors. Reconcile one
root at a time: a run is atomic against the rest of the loop today, so
wrapping it to run concurrently makes two runs against one root race on the
manifest.
Note two behaviour changes for existing users:
- `parse_ai_config` now fails closed on a `skills` value that is not a list
of `{key, version}` objects, where before any value parsed and was
ignored. A variation carrying its own differently-shaped `skills` field
must rename it before upgrading.
- `shutdown()` clears the configured skill store along with the client.
`Skill.frontmatter()` needs `pyyaml`, which is deliberately not a runtime
dependency of this package. It returns None rather than raising when the
library is absent or the block cannot be parsed. `agents.md` records this
alongside the rest of the package's dependencies and why each sits where it
does.
Every security guard has a test that fails if the guard is removed. Two are
worth naming, because the obvious test does not reach them. 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` → 1333 passed, 11 skipped. `ruff check`,
`ruff format --check`, and `mypy packages/*/src` all clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9570e8d to
02aeaa4
Compare
|
Split this into five stacked PRs so each one is small enough to read. This branch is untouched and will not be force-pushed — the review above keeps every one of its anchors, and this PR becomes the umbrella.
Per @knfreemLD's triage, findings 1, 2 and 3 are resolved in the stack; the rest are recorded against the slice whose code they touch.
Finding 1. Version is now part of the lookup identity rather than a filter applied afterwards: Finding 2. Finding 3. Took the first branch you offered: the constant keeps its value but is no longer exported from the package root, and its docstring now says what it actually is — the string the SDK hands a store, which an adapter maps
One unrelated thing found on the way: |
|
Closing in favor of the split, see PR description for links to the stacked PRs. |
**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_fs` unit tests. `main` ← #50 ← #51 ← #52 ← #53 ← **`split/skills-fs-hardening`** > [!NOTE] > **Aug 28:** rebased in place for the stack-wide bytes pivot (`Skill.content: bytes`, `frontmatter()` deleted). Test helpers here construct `Skill` values 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. - **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 containment check asserted on inode identity rather than on path strings. - **Symlink attacks** — a symlinked skill directory, a symlinked target file, and `<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. - **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" must not 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. Where the platform has `renameat` that 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. - **Telemetry** — the three signal names are asserted as an **allowlist, not a floor**: any other name reaching the emitter fails the test. The two deliberately excluded names are called out explicitly rather than left to the subset check. No signal carries a filesystem path or the skill body, an emitter that raises never fails the reconcile, and `client.track()` is never reached. ## Notes for reviewers Two cases 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*. 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 for `errors="surrogatepass"`. - The **redaction** cases smuggle the body through `contentHash` and through `key`. A sweep using a well-formed digest under a valid key reaches neither replacement branch. The set of tests in `test_skills_fs.py` at this tip matches #45's modulo the bytes pivot (helpers build `Skill.content` as `bytes`; the deleted `frontmatter()` 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`, and `mypy packages/*/src` all clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Tests only** — expands `test_skills_fs.py` so 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 before `os.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.md` renames), **symlink** refusal and descriptor-pinned TOCTOU cases (skipped when `SUPPORTS_DIR_FD` is 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). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c88d5bf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
…4/5) (#53) **PR 4 of 5** splitting draft #45 for review. Stacked on #52. `main` ← #50 ← #51 ← #52 ← **`split/skills-materialization`** ← `split/skills-fs-hardening` > [!IMPORTANT] > **Aug 28 repivot: skills are now opaque byte buffers by construction.** `Skill.content` is `bytes` (the verified verbatim bytes, exactly what was hashed), the `frontmatter()` convenience accessor and `frontmatter.py` are deleted, and the SDK no longer parses or interprets skill content anywhere. Consumers who want frontmatter parse it themselves. The stack was rebased in place to make each change in the slice that introduced the code; the TypeScript SDK is getting the mirror change (`content: Uint8Array`) separately. ## What's here `write_skills` writes `<root>/<key>/SKILL.md` and records what it owns in a manifest at `<root>/.launchdarkly-skills.json`, so it overwrites or removes **only** paths that manifest records. A file you placed yourself is reported and left untouched. ```python report = await write_skills(refs, ".claude/skills") for action in report.errors: print(f"skill {action.key or '<run>'}: {action.error}") ``` `skills` accepts `Skill` values, references, bare keys, or the literal `"*"` for everything the store holds. Every outcome is visible in the returned `ReconcileReport`: one `ReconcileAction` per skill carrying `written`, `updated`, `skipped_current`, `removed` or `error`, plus `.ok` and `.errors`. A failure belonging to the run rather than to one skill — an unreadable manifest, a retrieval that failed before any key was known — carries the **empty string** as its key; callers grouping a report by key need to expect that sentinel. New exports: `write_skills`, `ReconcileAction`, `ReconcileReport`, `ReconcileActionKind`, `OnUnavailable`, `SKILL_FILENAME`, `MANIFEST_FILENAME`, `MANIFEST_VERSION`. ## The defenses Writes are atomic, at mode `0644`, and every destructive step runs against a descriptor pinned to a directory that was already checked, so a path swapped after the check cannot redirect a write or an unlink out of the managed root. Where the platform has no `*at()` family the identical sequence runs against full paths. - The key is re-validated here regardless of upstream validation, before any filesystem call, because a key becomes a directory name. The data model allows 256 characters and `NAME_MAX` is 255 bytes, so an over-long key is refused too. - Never write or unlink through a symlink, on the write path or the prune path. - Destruction only on manifest-listed paths whose `key` matches. - A corrupt manifest fails closed: no overwrites and no prunes, brand-new paths may still be written, an `error` action names the manifest, and the manifest is not rewritten. - An incomplete retrieval suppresses pruning, so a transport outage cannot read as "everything was revoked". - Content is re-verified immediately before the write, because a `Skill` can also be constructed directly by a caller. With the bytes pivot, this pass hands `Skill.content: bytes` to the same `verified_bytes` the accessors use, which hashes the bytes directly — same two-pass design, identical telemetry property keys. Pruning removes formerly-managed skills that are no longer referenced — that is how revocation takes effect. `timeout` bounds retrieval, the writes and the pruning; only the final manifest rewrite runs past it, so files already written are never orphaned. The `"*"` form collapses to one object per key at its newest version, since `<root>/<key>/SKILL.md` is a single path and writing it twice in one run is a bug rather than a policy. It reports a withholding count at WARN for the same reason the accessors do (finding 2). ## Notes for reviewers - **`write_skills` blocks.** It is `async` for parity with the other accessors and with the TypeScript SDK, but it awaits nothing: every read, write, `fsync` and rename runs inline. Wrap it in `asyncio.to_thread` if that matters on your loop. For the same reason `timeout` is checked *between* steps rather than interrupting one already in progress. Reconcile one root at a time — because nothing yields today a run is atomic against the rest of your loop, and wrapping it to run concurrently makes two runs against one root race on the manifest. (Review findings 4 and 6 on #45 both live here and are tracked there, not resolved in this PR.) - **The root's parent must exist.** `write_skills` creates the root itself but never its ancestors, so a typo cannot scatter a directory tree across a project. An absent parent, a root that is an existing file, and a root that is a symlink each raise `ValueError` — caller errors, distinct from the per-skill `error` actions in the report. - **The security abuse matrix is PR 5.** Every guard listed above is in this diff; what lands next is the adversary that proves each one fails without it — path traversal, symlink attacks, the directory swap fired at the instant of the rename, clobber protection, corrupt manifests, atomicity under an injected crash, and the telemetry allowlist. This PR's own tests cover the happy path, reconcile semantics, manifest round-tripping, resilience, and verify-before-write. Splitting `skills_fs.py` itself along that line would have meant shipping a deliberately weakened `_write_one`/`_prune` here and hardening it there, which `agents.md` marks non-relaxable. ## Testing `uv run pytest` → 1216 passed. `ruff check`, `ruff format --check`, and `mypy packages/*/src` all clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **Agent Skills materialization**: `write_skills` reconciles verified skills to `<root>/<key>/SKILL.md`, tracks SDK-owned paths in `<root>/.launchdarkly-skills.json`, and returns a **`ReconcileReport`** (`written` / `updated` / `skipped_current` / `removed` / `error`, plus `.ok` / `.errors`). > > Implementation lives in new **`skills_fs.py`** (split from retrieval in `skills.py`), using descriptor-pinned **`safe_fs`** writes, manifest-authorized overwrites/deletes, fail-closed behavior on corrupt manifests, **no prune** when retrieval is incomplete or verification fails (so outages/tampering cannot masquerade as revocation), re-verify before write, and optional **`"*"`** / **`prune`** / **`timeout`** / **`on_unavailable`**. > > **Public API** expands via `__init__.py`: `write_skills`, `ReconcileAction` / `ReconcileReport` / `ReconcileActionKind`, `OnUnavailable`, and `SKILL_FILENAME` / `MANIFEST_FILENAME` / `MANIFEST_VERSION`. README and **`agents.md`** document the flow, blocking async I/O note, and invariants; **`skill_refs`** docs note that dropped invalid entries can otherwise cause unintended prune. > > Tests add **`test_skills_fs.py`** (happy path, manifest, reconcile, root errors, resilience) plus report/export coverage in **`test_skills.py`**; adversarial FS tests are deferred to a follow-up PR per description. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4c6d965. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
**PR 3 of 5** splitting draft #45 for review. Stacked on #51. `main` ← #50 ← #51 ← **`split/skills-safe-fs`** ← `split/skills-materialization` ← `split/skills-fs-hardening` > [!NOTE] > **Aug 28:** rebased in place for the stack-wide bytes pivot (`Skill.content: bytes`, `frontmatter()` deleted). No change to this slice's own content beyond the rebase. ## What's here `safe_fs.py`: the "write a file under a directory something else may be racing you for" problem, solved once. Nothing here knows what a skill is — the materialization layer (PR 4) is its only caller. **Why descriptors and not paths.** A path check is only as good as the last path resolution after it. Every `lstat` and containment check validates an *inode*, but a following `os.replace(tmp, dir / name)` re-resolves `dir` from its *name* — so anything holding write permission there can move the validated directory aside, leave a symlink in its place, and redirect the write or the unlink somewhere else. Narrowing that window is not a fix; the race is winnable at any width. So the checks hand off to a descriptor and nothing re-resolves a path afterwards. - `open_directory_nofollow` opens with `O_RDONLY | O_DIRECTORY | O_NOFOLLOW` and confirms `S_ISDIR` on the `fstat`, since not every platform defines `O_DIRECTORY`. `open_or_create_directory` adds `os.mkdir` plus an `lstat` on the `FileExistsError` path, because `Path.mkdir(exist_ok=True)` accepts a symlink-to-directory as "already there" and would reopen the hole the caller's check just closed. `pinned_directory` holds either for a block, so a caller states the platform split once as `if dir_fd is not None` and cannot forget the `os.close`. - `atomic_write` creates its temp file with `O_CREAT | O_EXCL | O_NOFOLLOW` **at** that descriptor (`_mkstemp_at`, since `tempfile` has no `dir_fd` form), `fchmod`s the descriptor rather than `chmod`ing a path, writes, fsyncs, renames, and fsyncs the directory so the rename survives a crash. Mode is set explicitly at `0644` — never inherited from the umask, never executable. `os.replace` is the single rename call site and `os.rename` must not be substituted for it. - `unlink_file` probes and unlinks descriptor-relative. `unlink` never follows a *trailing* symlink but it does resolve the directory above it, so the same swap turns a removal into a delete of an arbitrary file. A symlink found where this SDK expects its own file raises `SymlinkRefused` rather than being tidied away — the state on disk is not what the caller believes, and that is the caller's to report. **The capability probe is not the obvious one.** `SUPPORTS_DIR_FD` deliberately names `os.rename` and `os.stat` rather than the `os.replace` and `os.lstat` this module actually calls: `os.supports_dir_fd` is populated per underlying syscall, and CPython registers `renameat` under `rename` only and `fstatat` under `stat` only. Probing the names actually called reports "unsupported" on every POSIX platform and silently turns the defense off. Where the family is absent (Windows) every operation falls back to the identical full-path sequence, the per-component `lstat` floor. ## Notes for reviewers The 22 tests here exercise the module **directly**, on its own terms — they are new work, not present in #45, added so this slice does not ask you to read 311 lines of security-critical primitives with nothing asserting them. In #45 every one of these guarantees was reached only through `write_skills`. The TOCTOU races these primitives exist to close are proved in PR 5, which is what actually holds a descriptor across a sequence of operations and can fire a directory swap at the instant of the rename. ## Testing `uv run pytest` → 1167 passed. `ruff check`, `ruff format --check`, and `mypy packages/*/src` all clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Introduces **`safe_fs.py`**, a skills-agnostic layer for **descriptor-pinned** directory I/O so writes and unlinks don’t re-resolve paths after validation (closing TOCTOU symlink-swap races). On POSIX it uses `*at()`-style operations via `pinned_directory`, `atomic_write` / `atomic_write_in`, and `unlink_file` (with **`SymlinkRefused`** when a symlink stands in for an expected file); on Windows it falls back to the documented per-component `lstat` path when **`SUPPORTS_DIR_FD`** is false, using a non-obvious capability probe so CPython’s `os.supports_dir_fd` doesn’t silently disable the defense. > > Adds **`test_safe_fs.py`** (~22 tests) for pinning, atomic writes (mode `0644`, temp cleanup, single `os.replace` site), unlink behavior, and the dir-fd probe. **`agents.md`** documents the module in the file map and a new “Descriptor-pinned filesystem access” section. Integration with skill materialization is left to a follow-up slice; nothing else in the client imports this module yet. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9571dca. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
…/5) (#51) **PR 2 of 5** splitting draft #45 for review. Stacked on #50 — review that first; this diff is against it. `main` ← `split/skills-references` (#50) ← **`split/skills-retrieval`** ← `split/skills-safe-fs` ← `split/skills-materialization` ← `split/skills-fs-hardening` > [!IMPORTANT] > **Aug 28 repivot: skills are now opaque byte buffers by construction.** `Skill.content` is `bytes` (the verified verbatim bytes, exactly what was hashed), the `frontmatter()` convenience accessor and `frontmatter.py` are deleted, and the SDK no longer parses or interprets skill content anywhere. Consumers who want frontmatter parse it themselves. The stack was rebased in place to make each change in the slice that introduced the code; the TypeScript SDK is getting the mirror change (`content: Uint8Array`) separately. ## What's here The layer that turns a reference into content: an injectable store interface, integrity verification of everything it serves, and a body-free telemetry interface for the failures. | Export | Description | |---|---| | `get_skill(key, *, version=None)` | One verified skill, or `None`. | | `get_skills(refs)` | Batch form; accepts `SkillReference` values and bare key strings. | | `all_skills()` | Every verified skill the store holds, one per key at its newest version. | | `SkillStore` | The structural interface content arrives through: `get_object(kind, key, version=None)`, `all_objects(kind)`, optional `add_listener(kind, fn)`. | | `InMemorySkillStore(objects=None)` | Dict-backed store with `put(raw)`. Holds several versions of a key. | Configure with `init_client(options={"skillStore": store})`. The LaunchDarkly delivery transport drops in behind the same seam with no public API change. ## Verification Store data is untrusted; the transport is not part of the trust boundary. Key and version are revalidated, size is bounded, and the sha256 of the verbatim bytes must match the delivered `contentHash`. The wire object delivers content as a JSON string; the UTF-8 encode happens exactly once, inside verification, and the `Skill` handed to user code carries the verified verbatim bytes (`Skill.content: bytes`) — the exact byte sequence that was hashed. Anything that does not verify is withheld and treated as missing, so no unverified content is ever returned. `verified_bytes` also accepts already-`bytes` content and hashes it directly, for the pre-write re-verification pass PR 4 adds — the "not encodable as UTF-8" branch applies only to wire-shaped `str` input. Content carrying an unpaired surrogate has no UTF-8 encoding at all and is withheld too — `str.encode` is called strictly, never with an error handler that would fabricate bytes a hash comparison could then accept. Integrity failures go through a private telemetry seam carrying hashes and byte counts only. The two properties copied off the wire, `skill_key` and `expected_hash`, are shape-checked and replaced when malformed, so a hostile store cannot use either to publish the body through a signal that is otherwise body-free. The default emitter is a no-op; the three signal names are an allowlist maintained in one section of one module. ## Review findings addressed **Finding 1 (blocking) — version pinning could not be expressed by the store seam.** Version is now part of the lookup identity rather than a filter applied to the answer. A delivery payload carries the newest version of every skill *plus* every version any variation currently pins, so two versions of one key coexist routinely; a seam keyed by key alone answered a pinned reference with the newest object and then rejected it, turning the primary use case into a missing skill. - `SkillStore.get_object(kind, key, version=None)`; `version=None` means "the newest you hold". - The post-fetch equality check stays, now as a **defense** rather than the selection mechanism: the store is untrusted, so an answer that is not the version asked for is withheld. `TestVersionPinning::test_a_store_answering_with_the_wrong_version_is_withheld` covers it. - `InMemorySkillStore` keys by `(key, version)` and holds both. An object whose version is unusable is still served, under its key alone — withholding it is verification's job, so a malformed object stays distinguishable from an absent one and still records a signal. - `all_objects` returns one entry per `(key, version)` under keys documented **opaque**; identity is read off each object's own fields. `newest_by_key` is the single place that collapses a whole-store read to one object per key, because a list holding two versions of one key is not a set of skills. - `TestVersionPinning` holds two versions of one key and asserts a pinned-old lookup, a latest lookup, both against one store, a mixed batch, and `all_skills()` returning one entry per key. Reverting just the seam change fails three of them, so they are not vacuous. **Finding 2 — `contentHash` mandatory here, optional on the wire.** It stays mandatory; that default is right. What changes is the signalling: a run that withheld anything logs one WARN naming the counts, and a run where *nothing* verified says so explicitly and names `contentHash` — that case previously returned an empty result indistinguishable from "this project has no skills". `TestWithholdingSummary` covers total, partial, batch-scoped, and silent-on-success. The other half of finding 2 — whether the wire object needs a hashing-algorithm discriminator rather than a bare hex string — is a delivery-contract decision, not an SDK one; it is tracked on #45 rather than resolved here. **Finding 3 — the object kind may not match the real delivery kind.** `SKILL_OBJECT_KIND` keeps its value but is **no longer exported from the package root**. It is the string this SDK hands a store, and an adapter maps whatever the transport underneath calls a skill onto it; publishing it would advertise an SDK-side seam value as the wire contract — a claim this side cannot make, and hard to walk back once a caller depends on it. An adapter that needs to agree with it reaches it through `skills_core`. `test_object_kind_is_not_public_api` asserts it is absent from both `__all__` and the package namespace. **This needs the same change in the TypeScript SDK**, as does finding 1's seam shape. `MAX_SKILL_CONTENT_BYTES` stays internal for the adjacent reason: it is a local enforcement bound on content the platform produces, so exporting it would semver-lock a number this side does not own. ## Notes for reviewers - **Behaviour change.** `shutdown()` clears the configured skill store along with the client. `init_client` applies `skillStore` on every successful call, even the idempotent ones, which is what lets a lazily auto-initialized client be given a store afterwards. - `record_materialized` and `record_revoked` land here with no caller — the materialization layer is PR 4. They live beside `record_integrity_failure` so the three-signal allowlist is one section of one file rather than three sites to audit. ## Testing `uv run pytest` → 1145 passed. `ruff check`, `ruff format --check`, and `mypy packages/*/src` all clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > This PR adds the **content retrieval** layer for Agent Skills: verified skill bodies are read through an injectable **`SkillStore`**, wired via **`init_client(options={"skillStore": ...})`**. > > **Public API:** **`get_skill`**, **`get_skills`**, **`all_skills`**, **`SkillStore`**, and **`InMemorySkillStore`** join existing **`skill_refs`**. Accessors return **`None`** or omit entries when content is missing or fails checks; they **`RuntimeError`** only if no store is configured. Lookups are **version-aware** (`get_object(kind, key, version=None)`) so pinned references resolve to the intended version, not always the newest. > > **Internals:** New **`skills_core.py`** centralizes store/emitter state, **`verify_raw_skill`** / **`verified_bytes`** (required **`contentHash`**, 10 MiB cap, strict UTF-8), shared **`resolve_from_store`**, and a three-signal telemetry allowlist (default no-op emitter). Withheld skills emit stable **`ld.skills.integrity_failure`** ERROR logs (SIEM contract) plus optional product signals; run-level **WARN** summaries when batches withhold content. > > **Lifecycle:** **`init_client`** applies **`skillStore` on every successful call** (even when the LD client is already initialized) but never clears an existing store unless **`shutdown()`** (which also clears skill module state). Failed init does not install a store. > > **Docs/tests:** README and **`agents.md`** document integrity detection and security invariants; **`test_skills.py`** expands heavily (version pinning, withholding, log record shape, telemetry allowlist). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit daf04a6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
skill_refssafe_fs.py— descriptor-pinned filesystem primitiveswrite_skills, the manifest, reconcilemain← #50 ← #51 ← #52 ← #53 ← #54The tip of the stack has the same tree as this branch, plus the changes for review findings 1, 2 and 3 and 22 new direct unit tests for
safe_fs.make lint,make format-check,mypy packages/*/srcand the full test suite are clean on every slice, not just the tip.Where the review findings landed
contentHashmandatory here, optional on the wireSKILL_OBJECT_KINDmay not match the delivery kindwrite_skillsisasyncbut fully synchronousall_skills()andwrite_skills("*")Skill/SkillReferenceaccept invalid values from direct callersget_skillsfrontmatter()depends on a dev-only dependencyFinding 1 — the fix
Version is now part of the lookup identity rather than a filter applied to the answer:
SkillStore.get_object(kind, key, version=None), whereNonemeans "the newest you hold". The post-fetch equality check stays, but as a defense against an untrusted store answering with the wrong object rather than as the selection mechanism.InMemorySkillStorekeys by(key, version)and holds both;all_objectsreturns one entry per(key, version)under keys documented opaque, withnewest_by_keyas the single place that collapses a whole-store read to one object per key.TestVersionPinningholds two versions of one key and asserts a pinned-old lookup, a latest lookup, both against one store, a mixed batch, andall_skills()returning one entry per key. Reverting only the seam change fails three of them, so they are not vacuous.Finding 2 — the fix
contentHashstays mandatory; that default is right. What changed is the signalling: a run that withheld anything logs one WARN naming the counts, and a run where nothing verified says so explicitly and namescontentHash— previously that returned an empty result indistinguishable from "this project has no skills".Still open on the delivery side: whether the wire object needs a hashing-algorithm discriminator rather than a bare hex string. The SDK assumes SHA-256 lowercase hex. That is a wire-contract decision rather than an SDK one, so it is not resolved in the stack.
Finding 3 — the decision
SKILL_OBJECT_KINDkeeps its value but is no longer exported from the package root. It is the string the SDK hands a store, and an adapter maps whatever the transport underneath calls a skill onto it; publishing it would advertise an SDK-side seam value as the wire contract — a claim this side cannot make, and hard to walk back once a caller depends on it.test_object_kind_is_not_public_apiasserts it is absent from both__all__and the package namespace, and it stays reachable throughskills_corefor the adapter that needs to agree with it.This deliberately does not pick a kind/category string, so it needs no confirmation from the delivery side to land.
Findings 1 and 3 both change a seam the TypeScript SDK mirrors. The strings are untouched, but the shape is not:
get_object(kind, key, version)— the protocol needs the same third parameter in TypeScript.SKILL_OBJECT_KINDshould be un-exported there too, for symmetry.Also worth knowing
make typecheck(mypy ., including tests) fails onmainalready, withDuplicate module named "conftest"(packages/ai/testsvspackages/claude-agents/tests), which aborts before checking anything. Pre-existing and unrelated to this work;mypy packages/*/srcis the gate every slice passes. Worth a separate fix with--explicit-package-basesor an__init__.py.Original description
Adds support for Agent Skills: versioned
SKILL.mddocuments managed in LaunchDarkly and attached to Config variations by reference. The SDK surfaces which skills a config references, retrieves their content, and materializes them onto disk where agent runtimes discover them.Public API
skill_refs(config)skillsarray intolist[SkillReference]. Pure — no client, store, or network.get_skill(key, *, version=None)None.version=Nonemeans newest available.get_skills(refs)SkillReferencevalues and bare key strings.all_skills()write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep")root, returning aReconcileReport.New types:
Skill,SkillReference,ReconcileAction,ReconcileReport, plus theReconcileActionKindandOnUnavailableunions and the fixed on-disk / on-the-wire constants — all exported from the package root.How content arrives
Content comes through an injectable
SkillStoreseam —get_object(kind, key),all_objects(kind), and an optionaladd_listener(kind, fn)— configured withinit_client(options={"skillStore": store}).InMemorySkillStoreships for local development and testing.The LaunchDarkly delivery transport is a follow-up release and drops in behind the same seam with no public API change.
Verification
Content is verified before it reaches caller code: key and version are revalidated, size is bounded, and the sha256 of the verbatim UTF-8 bytes must match the delivered
contentHash. Anything that does not verify is withheld and treated as missing, so no unverified content is ever returned.Materialization
write_skillswrites<root>/<key>/SKILL.mdand records what it owns in a manifest, so it overwrites or removes only files it wrote — a file you placed yourself is reported and left untouched. Writes are atomic, at mode0644. Pruning removes formerly-managed skills that are no longer referenced, which is how revocation works. Every outcome is visible in the returnedReconcileReport(.actions,.ok,.errors).write_skillsperforms synchronous filesystem I/O and does not yield — it isasyncfor signature parity with the other accessors. Wrap it inasyncio.to_threadif that matters on your loop.Notes for reviewers
Skill.frontmatter()needspyyaml, which is deliberately not a runtime dependency of this package. It returnsNonerather than raising when the library is absent or the block cannot be parsed.examples/skills_example.pyruns the whole path end to end, fully offline — no SDK key, no API key, no sockets.mainand is behind it; it needs a rebase before merge.Testing
uv run pytest packages/→ 856 passed.ruff check,ruff format --check, andmypy packages/*/srcall clean.🤖 Generated with Claude Code