Conversation
First of five slices splitting the Agent Skills feature for review. This one
adds the layer with no I/O in it at all: the types, the validation, and the
projection from a resolved AI Config to the skills it references.
- `skill_refs(config)` projects a config's `skills` array into
`list[SkillReference]`. Pure — no client, no store, no network, no telemetry.
- `Skill` and `SkillReference` are frozen dataclasses, exported from the package
root. `Skill.content` is `bytes` — the verified verbatim bytes LaunchDarkly
delivered, exactly what was hashed. Skills are opaque byte buffers by
construction: the SDK never parses, decodes, or interprets skill content
anywhere. `content_hash` is the sha256 (lowercase hex) over those bytes, and
the optional display metadata comes from LaunchDarkly, never from the content.
- `parse_ai_config` now validates the optional `skills` array and fails closed
on a malformed one. Key grammar, length bound, and the version predicate live
in `types_validation.py` as one canonical rejection reason, so every layer
added on top rejects a key for the same stated reason.
Note one behaviour change for existing users: `parse_ai_config` 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.
The two layers that follow — retrieval through a store seam, and
materialization onto disk — are separate slices. `agents.md` names all three
and the one-way dependencies between them.
Testing: `uv run pytest` → 1048 passed. `ruff check`,
`ruff format --check`, and `mypy packages/*/src` all clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second of five slices. Adds the layer that turns a reference into content: an
injectable store seam, integrity verification of everything it serves, and a
body-free telemetry seam for the failures.
- `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, one per key.
- `SkillStore` is the structural interface content arrives through —
`get_object(kind, key, version=None)`, `all_objects(kind)`, and an optional
`add_listener(kind, fn)` — configured with
`init_client(options={"skillStore": store})`. `InMemorySkillStore` ships for
local development and testing. A 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 bytes must match the delivered `contentHash`;
anything that does not verify is withheld and treated as missing, so no
unverified content is ever returned. 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, never a
re-derived value. 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.
`verified_bytes` also accepts already-bytes content, hashing it directly, for
the pre-write re-verification pass a later slice adds.
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. The default emitter is a no-op:
nothing leaves the process in this release, and the three signal names are an
allowlist maintained in one section of one module.
Version is 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 would answer a pinned reference with the
newest object and then reject it, turning the primary use case into a missing
skill. `InMemorySkillStore` holds several versions of a key, `get_object` takes
the wanted version, and `version=None` means "the newest you hold". The
equality check afterwards is kept as a defense — the store is untrusted, so an
answer that is not the version asked for is withheld.
`all_objects` returns one entry per key-and-version under keys that are opaque
to this SDK; identity is read off each object's own fields. `newest_by_key` is
the single place that collapses the result to one object per key.
A run that withheld anything now logs a count at WARN. Every individual
withholding already records a signal and an error line, but a caller reading
logs at WARN saw neither, and a payload where nothing verifies otherwise
returns an empty result indistinguishable from "this project has no skills".
`SKILL_OBJECT_KIND` is deliberately **not** 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. An adapter that needs to agree with it reaches it
through `skills_core`. `MAX_SKILL_CONTENT_BYTES` stays internal for the
adjacent reason.
Note one 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.
Testing: `uv run pytest` → 1145 passed. `ruff check`,
`ruff format --check`, and `mypy packages/*/src` all clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An integrity failure now writes a structured, machine-parseable ERROR record on the SDK's own logger, designed to be ingested by a SIEM and alerted on. This is the detection path that works when telemetry is off, and the only one that exists at all in an instance with no telemetry destination — so it is a documented contract rather than a debugging aid. The LD-side counter is left exactly as designed: opt-out respecting, no-op by default, property set unchanged. `reason_code` lives in the log record only. - `ld.skills.integrity_failure` is the stable event name, and it appears in the message text rather than only in `extra`. Severity cannot discriminate — a raising store also logs ERROR from this module — and the stdlib's default formatter drops `extra`, so an `extra`-only record is invisible under a plain `logging.basicConfig()`. - The message is the event name plus compact key-sorted JSON, so the line is greppable, `jq`-able, and byte-identical across LaunchDarkly's AI SDKs for the same input. The same mapping is attached as `extra["ld_skills"]`. - `reason_code` is a closed vocabulary of eight tokens, one per `record_integrity_failure` call site, typed as a `Literal` so a typo at a call site is a type error. - The record spreads the signal's properties rather than rebuilding them, so the two cannot drift on which fields are redacted or omitted. Optional fields are omitted, never nulled. No new untrusted value, and no path. Documented for customers in the README and for contributors in agents.md, including the full vocabulary, so a ninth reason cannot land in one language only.
Third of five slices. Adds `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 is its only caller, and it lands next. 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 and cannot forget the close. - `atomic_write` creates its temp file with `O_CREAT | O_EXCL | O_NOFOLLOW` at that descriptor, `fchmod`s the descriptor rather than 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 and 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. `SUPPORTS_DIR_FD` gates all of it, and the probe deliberately names `os.rename`/`os.stat` rather than the `os.replace`/`os.lstat` this module 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, every operation falls back to the identical full-path sequence. The tests here exercise the module directly, on its own terms. The TOCTOU races these primitives exist to close are proved through the materialization layer, which is what holds a descriptor across a sequence of operations. Testing: `uv run pytest` → 1260 passed, 11 skipped. `ruff check`, `ruff format --check`, and `mypy packages/*/src` all clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Fourth of five slices. Adds `write_skills`, which writes
`<root>/<key>/SKILL.md` and reconciles against a manifest recording what the
SDK owns, so it overwrites or removes only files it wrote — a file you placed
yourself is reported and left untouched.
report = await write_skills(refs, ".claude/skills")
`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.
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 defenses, all of them deliberate and all of them tested:
- 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.
Pruning removes formerly-managed skills that are no longer referenced, which 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.
`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.
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, and it reports a withholding count at WARN for the
same reason the accessors do.
The security abuse matrix — path traversal, symlink attacks, clobber
protection, corrupt manifests, atomicity under an injected crash, and the
materialization telemetry allowlist — is the next slice. The guards it exercises
are all here; what lands next is the adversary that proves each one fails
without them.
Testing: `uv run pytest` → 1216 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>
…esystem can hold Two gaps from the Agent Skills security design review, both in `skills_fs.py`: AV-3 (partial reconciles are unrecoverable) and DV-2 (the key grammar admits Windows device names). **AV-3 — adopt a file whose bytes already are the resolved content.** `_write_all` reconciles every skill and only then rewrites the manifest, once, last. A process killed in that window leaves a skill file at a managed path with no manifest entry — exactly the condition `_write_one` treats as an unmanaged-file collision, so the skill was wedged permanently: every later reconcile took the same refusal branch. Boot-time execution under a ten-second budget makes the crash window realistic. `_write_one` now reads and hashes first and decides from the bytes. Content byte-identical to what LaunchDarkly resolved is adopted — manifest entry recorded, reported `skipped_current` — and anything else falls through to the same refusal as before. This cannot weaken the clobber guarantee: differing unmanaged bytes are never overwritten, and the existing clobber tests pass unchanged. Three details carry the safety: - A read that fails is a refusal, never an overwrite, with a message distinguishable from the byte-mismatch refusal — it is the comparison that would otherwise authorize the write, and a file that could not be read has not been shown to be ours. - The read stays on `_read_regular_file`. Adoption widens it to genuinely foreign files, so its refusal of FIFOs and other non-regular files is now load-bearing rather than defensive. It gains a `max_bytes` bound of `len(content) + 1` — enough to prove inequality for anything longer, and what keeps a foreign file of arbitrary size out of memory. A bound of exactly `len(content)` would adopt every file that merely begins with the resolved content. - `skipped_current` is reused rather than adding an `adopted` action kind, so `ReconcileActionKind` — public, and owned by an approved PR — does not change. Its documented meaning already fits. Adoption also makes the file prunable later. That is correct rather than a weakening: only byte-identical LaunchDarkly content is ever adopted, so a later prune removes content LaunchDarkly delivered anyway — what would have happened had the crash not occurred. The review also floats a write-intent journal. Assessed as over-engineered; not built. **AV-3, secondary — sweep orphaned temp files.** `atomic_write` unlinks its temp file on any exception but not after a `SIGKILL`, and `_prune` walks manifest entries, which an orphan never has, so nothing would ever notice one. The second-order effect is worse than the disk: `_prune_one`'s `rmdir` only succeeds on an empty directory, so a single orphan pins a skill's directory forever. The sweep runs on both the write and the prune path, and is the one place this SDK removes a file the manifest does not list, so it is bounded on every axis: inside `<root>/<key>/` only, for a key that passes `_key_rejection_reason`; only names `safe_fs` itself recognizes, via a new `is_temp_name` beside the naming code rather than a copy of the format string that could drift from it; only regular files, with the type read off the descriptor; unlinked through the pinned descriptor. It never raises and never aborts a run. **DV-2 — reject the 22 Windows reserved device names.** `con`, `prn`, `aux`, `nul`, `com1`–`com9`, `lpt1`–`lpt9` are all valid skill keys and none can be a directory name on Windows. Rejected in `_key_rejection_reason`, which the write and prune paths already share, and *not* in the key grammar: `parse_ai_config` fails closed, so a grammar-level rejection would invalidate an entire AI Config for a Linux customer over a Windows-only constraint, and would silently shrink `skill_refs` — which is what authorizes a prune, turning "fails to write on Windows" into "gets deleted on Linux". The 255-byte component bound is in this layer for the same reason. Unconditional, not platform-gated: a root written from a Linux container is routinely read from a Windows host, and neither repository has a Windows CI runner, so a gated branch would be untestable — the condition that produced the gap. No suffix stripping and no case folding: the grammar admits no `.` and no `$`, so `con.txt` and `CONIN$` are unreachable, and keys are lowercase-only. `com0` and `lpt0` are not reserved and are not included. The trade is real and belongs in the release notes: a customer who legitimately names a skill `aux` now gets a reported `error` action on Linux where it previously worked. Neither gap emits the integrity-failure log record. A key rejection and a clobber refusal are `ReconcileAction` errors, not integrity failures. Tests cover crash-mid-reconcile recovery end to end (adopted, reported, recorded, and the next reconcile an ordinary no-op), byte-differing content still refused untouched, an unmanaged FIFO refused without hanging, a read failure refusing rather than overwriting, the `len + 1` off-by-one, the sweep and the `rmdir` it unblocks, lookalike temp names and a symlink wearing one left alone, all 22 reserved names through both destructive paths, and — the point of the layer choice — each reserved name still valid to `is_valid_skill_key`, `parse_ai_config`, and `skill_refs`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…failure ``get_skill`` returns ``None`` for four unrelated outcomes: no such skill, the store raised, the requested version is not the one held, and content that failed hash verification. A caller cannot fail closed on suspected tampering while tolerating a merely-absent skill, so no automated customer-side response is possible — finding LA-2 of the Agent Skills security design review. The information already existed internally, as prose in ``Resolution.error``. This gives it a token: ``Resolution`` grows a typed ``reason``, set explicitly at every construction site and declared without a default so a sixth outcome added later has to choose which public token it maps to. ``get_skill_result`` maps that straight through to a frozen ``SkillOutcome`` (``skill``, ``reason``, ``detail``). Deriving the public reason by matching the error string is the fragility LA-2 is about, so the mapping is readable in one table. ``get_skill`` is untouched — its ``None``-for-every-failure contract is documented in its docstring and in the README, and a test now pins that all four failures still collapse to ``None`` and still never raise. Nothing new is emitted: Gap 1's integrity record already fired inside verification before ``resolve_from_store`` returned, and a test asserts one failed retrieval still produces exactly one record and one signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… remainder The four items left open in the response to the Agent Skills security design review. Three are documentation, one is tests; no behavior changes, and the code halves of rows 9 and 2 are deliberately untouched. **Privilege separation** (row 9 docs half, and the agreed counter-proposal for row 26). The recommended deployment runs the reconcile as a different identity than the agent, which is the whole reason the ``0644``/``0755`` modes deny anything: the agent reads its instructions and cannot rewrite them, or the manifest. That is the mitigation for AZ-1, a prompt-injected agent editing its own skills. Write access to the manifest is the worse half — it is what tells the *next* reconcile which paths the SDK may delete — which is why ``_prune`` re-validates every entry from scratch rather than trusting it. The README documents the pattern and hands the operator the check to run, because the SDK cannot run it: it knows only its own identity, which trivially has write access, having just written there. So ``ReconcileReport`` grows no writability field — the review asked for one and we declined, since any check the SDK could make would answer a different question than the one asked and manufacture false confidence exactly where caution is wanted. ``agents.md`` records that reasoning so the field is not added later by someone reading its absence as an oversight. **Three hostile-manifest prune tests** (row 12 remainder): a well-formed manifest listing ``/etc/passwd``, ``../../../etc/passwd``, and a path under a parent that has since become a symlink. ``_prune`` already refuses all three, so these turn asserted into verified. Two things make them worth more than their line count. They are deliberately *well-formed* — the corrupt-manifest suite above them proves nothing here, because a corrupt manifest suppresses every destructive action wholesale, whereas these manifests give the implementation everything it needs to prune. And "deleted nothing" is asserted through an unlink spy rather than by checking that ``/etc/passwd`` still exists: the test process cannot delete that file anyway, so the obvious assertion would pass against an implementation with no path check at all. **One sentence on** ``"*"`` (row 16 remainder). It materializes the whole project library, so every skill's ``description`` enters the agent's context — including skills no AI Config references and skills belonging to other teams. **The Windows platform bound is now explicit** (row 2 residual), in ``safe_fs.py``, ``agents.md`` and the README. Reparse-point checks (``GetFileAttributesW`` / ``FILE_FLAG_OPEN_REPARSE_POINT``) are not implemented, by decision: Windows is not a supported or tested platform for this release, neither repository has a Windows CI runner so the checks would ship unverified, and the TypeScript SDK could not match them in any case because Node exposes no ``*at()`` family on *any* platform. Implementing them in Python alone would break cross-language parity and trade a documented bound for an unverified one. Two consequences are recorded rather than left to be rediscovered: on Windows, write permission on the managed root is the only boundary, which is what makes privilege separation the mitigation and not merely advice; and this retroactively lowers the priority of the row 25 reserved-device-name work, noted where that code lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…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>
write_skills is a one-shot reconcile, so a revocation takes effect at the next process restart. watch_skills runs that reconcile now and again whenever the configured store reports a change, so a revoked skill's files leave the disk within a debounce interval of the store learning about it. It is wired to the SkillStore interface, not to any one transport: it needs a store that implements add_listener and nothing more, and refuses loudly when the store does not, since a watcher that silently never fires looks exactly like one whose skills never changed. Above the interface, remove_listener joins add_listener as the optional second half of change notification, on the SkillStore contract and on InMemorySkillStore. SkillWatcher.close needs it to detach; without it a store held every watcher ever created for the rest of its life. The watcher probes for it, so a store without it keeps working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…network The half of the delivery transport that has no I/O: identifying a skill object on the wire by kind inline-resource plus category skill, translating it into the raw object shape the SkillStore interface defines, holding it by (key, objectVersion), and applying a payload's events as one commit at payload-transferred. The store that puts a connection underneath this follows separately, so the three decisions that matter most can be reviewed on their own: - objectVersion is the skill's version; version is the payload's. The translation happens in one place and TestVersionTranslation asserts it in both directions, because confusing them fails silently. - Changes commit at payload-transferred, not per object. A half-applied full transfer would briefly empty the store, which with pruning on is the difference between a reconcile and deleting a customer's files. - A hashless object is held, not dropped, so verification withholds it with a reason code rather than the transport reporting it absent. Flag and segment objects share the connection and are skipped and counted, not rejected. Nothing here is exported yet; the store exports it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
``InMemorySkillStore.get_object`` consulted the version-less entry on any pinned miss, while the unpinned path consulted it only when nothing well-formed was filed under the key. A key holding both well-formed versions and one malformed object therefore answered a pin for an undelivered version with the malformed object, and verification recorded an integrity failure — an alert pointed at a skill whose integrity was never in question — where the honest answer is that the version is not held. Both paths now follow the one rule: the version-less entry answers only when nothing well-formed is filed under the key, which is the case it exists for. A malformed object that is all the store holds still reaches verification and is still withheld with a signal, so tampering cannot read as a skill that was never delivered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two untrusted-store answers were read as data rather than as failures.
``list_raw_objects`` collapsed a non-mapping listing to ``{}`` with no
error, so a store that served nothing usable was indistinguishable from
one holding no skills. ``resolve_from_store`` read identity off the
object without checking it against the key that was asked for, so an
answer served under a different key came back under the caller's key
while carrying its own.
Both are now withheld and reported, alongside the version check that
already guarded the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oncile watch_skills awaited write_skills and only then constructed SkillWatcher, which is where the store listener attaches. The reconcile snapshots the store as its first step and then spends the rest of its time on the filesystem, so every write, fsync, prune and manifest rewrite in that first pass ran with nothing listening. A change delivered in that window was never seen, and since nothing re-reconciles on a timer, a revocation that landed there waited for the next unrelated change — on a quiet root, the next restart. Exactly the gap watch_skills exists to close. The watcher now attaches its listener before the initial reconcile and starts its worker after. notify only sets an event, so a change arriving mid-reconcile is recorded and picked up by the worker's first pass, while holding the thread back keeps write_skills's one-root-one-reconcile contract: the worker cannot race the caller's own reconcile over the same manifest. A reconcile that raises detaches the listener on the way out, since the caller is handed an exception rather than a watcher to close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the withheld-answer fix for broken store answers, which the materialization path above this branch has regression tests for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the withheld-answer fix the reconcile regression tests below depend on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the reconcile side of the withheld-answer fix: a listing that is not a mapping leaves every managed file alone rather than reading as a full revocation, and an answer served under a different key writes nothing, is reported against the key that was asked for, and does not reach that other key's file. Each one previously deleted a file and reported a clean run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The protocol reader took payloads[0]'s intentCode and applied it to the skill object set, which is what the delivery protocol requires — one payload per credential, read the first intent, tolerate the rest — but it left the assumption behind that rule undocumented and unguarded. If the one-payload guarantee ever widens, an xfer-full for another payload would start an empty pending set and the next payload-transferred would publish it: every skill reported revoked, and with pruning on, a customer's files deleted. The first payload is still the payload that is read. What is new is that the reader now knows which payload skills actually arrive on — learnt from the intent's id, or from the (p:<id>:<version>) selector, since no object or transfer event carries a payload id of its own — and declines to apply a transfer of any other, holding last known good, warning once, and counting it in diagnostics.payloads_ignored. An intent describing more than one payload warns once on its own, because that is the one case the comparison cannot catch: another payload's transfer arriving before any skill has been seen has nothing to be compared against. Behaviour under one-payload delivery is unchanged, and a full transfer of the skill payload still empties it — every skill deleted is a real state the guard must not mask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SDK-facing FDv2 channel now delivers skills the way streamer #4681 and gonfalon #70638 spell them: object kinds are open strings, the agent-skill payload is classified `generic`, and every generic object carries only `key`, `kind`, `version` and `object`, exactly like a flag. A skill arrives under kind `skill` with its own version folded into the key as `<key>:<version>`. There is no `category` field and no `objectVersion` field; both came from an earlier streamer draft that never shipped. Identification is now the kind alone. The wire key is split in one place, `_split_wire_key`, and both the put and the delete translation go through it. A key that will not split cleanly is held rather than dropped — version-less, or with the offending text as its version — so verification withholds it with `invalid_version` under a key the caller recognises; only a key with nothing before the delimiter is dropped, since there is no identity to hold it under. `SDK_DATA_MODEL_VERSION` goes with it: the connection's `mv` parameter only accepts flag model versions, and generic payloads ignore it. The transport stops sending it in the following change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FDv2SkillStore puts LaunchDarkly's SDK-facing FDv2 channel underneath the protocol layer: GET /sdk/poll and GET /sdk/stream, authenticated with the environment's server-side SDK key, streaming by default. It carries basis across requests, sends If-None-Match and treats 304 as a current answer, retries with capped jittered backoff, honours Retry-After only up to max_backoff, gives up after a bounded run of consecutive failures where a committed payload resets the count, and keeps serving last known good through every failure. A mobile key or client-side environment ID is refused in the constructor. Standard library only. close interrupts the socket rather than only setting a flag, because the delivery thread lives in a read no flag can reach; without that every shutdown of a healthy stream waited out the full join timeout. The no-store message now names FDv2SkillStore first, and watch_skills points at it as the store with a delivery transport. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_Requester.stream wrapped only the connect as recoverable, so a read timeout, reset or truncated chunk in the body reached the delivery loop as whatever the socket raised. The loop read that as a bug and gave up: delivery stopped for the process lifetime, taking updates and revocations with it, the first time a socket died. read_timeout exists to bound a stream that has gone quiet so the loop can reconnect, and tripping it did the opposite. The body now carries the same promise the connect already did. Wrapping the line source rather than the whole read keeps protocol reader errors out of it: those are raised from the consumer's loop body, where they still surface as the bugs they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client-side cap was 64 KiB, close enough to the platform's own limit that any backend increase would force an SDK release. Raise it to 10 MiB so the guard stays a backstop against absurd input rather than a second enforcement of a bound this side does not own, and the real limit can grow without the SDKs moving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n promptly Three faults in the delivery loop, all of which left the store reporting itself healthy while doing less than it claimed. **An up-to-date stream tripped the failure cap.** The consecutive-failure count reset only at a commit, and an environment whose skills are not changing answers every reconnect with `intentCode: "none"` and transfers nothing. A stream only ever ends by being dropped, so each recycle of a perfectly healthy idle connection counted as a failure — announced with a `goodbye` or not — and `max_consecutive_failures + 1` of them stopped delivery for the process lifetime, revocations included. The reset on commit covered only the case where content had changed, which is the case that was easy to test and not the case that runs in production. `_TransferOutcome` now reports `up_to_date`, and a complete answer that transfers nothing breaks the row of failures exactly as a commit does. An intent this module does not recognise is still not an answer. **`close` could not interrupt a poll.** The interrupt reached the streaming connection only, so polling parked in its request with nothing to reach and `close` returned when its join timed out — on a 300s-class request, long after the process meant to exit. `_Requester` now tracks the response of a poll in flight and offers `interrupt`, which `close` calls alongside the stream's own. A request still inside its connect has no response to reach; that one is bounded by `read_timeout`, and `start` no longer leaves the store inert when a join times out around it. An interrupt we asked for is no longer recorded as a delivery failure. **`close` left a waiter parked.** `wait_for_skills` waited on the first payload alone, so a shutdown racing a waiter added the waiter's whole timeout to it. Delivery ending is now its own event: a waiter is released by a payload, a give-up or a close, and reports whether a payload actually arrived rather than merely that it was let go. That also settles what `_give_up` had been quietly asserting — it set the first-payload flag to unblock waiters, which made `wait_for_skills` answer `True` for a store holding nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stream only ever ends by being dropped, and LaunchDarkly — and any proxy in between — recycles a long-lived one. Every reconnect therefore logged "Skill delivery failed" at WARNING, for as long as the process ran. Until the previous commit that noise was bounded, because an idle stream gave up after eleven recycles and went quiet; now that delivery correctly survives them, it would run forever and describe a healthy store as failing. A connection that got a complete answer before it ended — a committed payload, or an up-to-date intent — delivered everything it was asked for, so its reconnect is now DEBUG and says so. A connection that ended without answering is the case the warning exists for and still gets it: a connect that never landed, or a transfer that died part-way through. Filling a customer's logs with a fault they do not have is not merely untidy; it teaches them that the level which means something can be ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stacked on the protocol-layer PR (`xie/skills-fdv2-protocol`). Third of three PRs split out of #69, and the one that makes the feature real: the network underneath the protocol reader, exported as `FDv2SkillStore`. ## Why this shape Skill content arrives over `GET /sdk/poll` and `GET /sdk/stream`, authenticated with the environment's server-side SDK key. These are the SDK-facing endpoints the base SDK's FDv2 data source uses, and the channel that payload signing will eventually cover. No private route is involved, and no credential other than the environment's own SDK key ships to a customer host. Standard library only, so the content path adds no dependency to a package whose sole runtime dependency is `opentelemetry-api`. ## What's here **`FDv2SkillStore`.** Authenticates, streams (default) or polls, carries `basis` across requests, sends `If-None-Match` and treats 304 as a first-class current answer, and serves `get_object` / `all_objects` / `add_listener` / `remove_listener` from what the protocol reader has committed. Capped jittered backoff; `Retry-After` honoured but clamped to `max_backoff` and rejected when non-finite; bounded consecutive-failure retries, where a committed payload resets the count. One network timeout, `read_timeout`, whose default follows the mode: 10s for a whole poll, 300s between reads on a stream. A mobile key or client-side environment ID raises from the constructor. Last known good survives every failure; `diagnostics` and `failed` report the degradation. **`close` interrupts the socket.** The delivery thread parks in a read no flag can reach, and closing a urllib response from another thread does not unblock CPython's buffered reader, so `_interrupt_read` shuts the socket down underneath it. Without that every shutdown of a *healthy* stream blocked for the full join timeout. **Above the interface**, two strings: `NO_STORE_MESSAGE` now names `FDv2SkillStore` first, since it is the first thing a user sees on a missing store and offering only the development store was wrong once a production transport existed; and `watch_skills`' refusal message names it as the store with a delivery transport. ## Bugs found and fixed while testing the loop Five, all sharing one shape: the store stopped delivering while continuing to report itself healthy. - **The consecutive-failure counter never reset in stream mode.** `_stream_once` always ends by raising, so a reset on return was unreachable and `failures` grew for the whole process lifetime. Eleven *fully successful* payload transfers were enough to trip `max_consecutive_failures` and stop delivery for good, revocations included. A commit now resets the count, in `_apply`. - **A non-finite `Retry-After` killed the delivery thread.** `float("inf")` parses, and `Event.wait(inf)` raises `OverflowError` from inside the recoverable-error handler. Non-finite values are rejected and every honoured delay is clamped to `max_backoff`. - **`close()` during the initial connect waited out its full join timeout.** `self._connection` was assigned after the connect returned, so a `close()` in that window found nothing to interrupt. The stop flag is re-checked immediately after the assignment. - **`close()` blocked for the full join timeout on every healthy stream.** See `_interrupt_read` above. - **A stream interrupted by our own `close` was reported as a delivery failure.** Also: `connect_timeout` was accepted and never used, so a poll against a black-holed host hung for 300s rather than 10. It is gone, with the request timeout now chosen by mode, and `TestTimeouts` measures the bound against a socket that accepts and never answers. ## Tests > **Rebase note.** The previous push of this branch had silently reverted the protocol PR's last commit (payload identity: `payloads_ignored`, `_is_foreign_payload`, `TestPayloadIdentity`). Rebasing onto the updated protocol branch restored it; the full suite passes with it present. `_FakeFDv2Endpoint` is an in-process `ThreadingHTTPServer` implementing the wire contract, so request construction and header handling are exercised over real sockets rather than mocked. Covers skill put/delete over the wire, mixed payloads, 304, `basis` round-tripping, reconnect/backoff in both modes, `Retry-After` including non-finite and oversized values, bounded retries and the reset on commit, prompt shutdown during connect and during a healthy stream, hashless envelopes end to end through the accessors, server-side-only credentials, timeouts, and `watch_skills` over the transport: a wire-level revocation pruning a file without a restart. Full suite 1629 passing, 11 skipped; `ruff`, `ruff format`, and `mypy` clean. ## Open items, none in this PR's scope - 🔴 **`contentHash` is not on the wire yet.** Against a real environment today every skill resolves to nothing. This PR makes that loud (an error per hashless object, a summary per wholly-hashless payload, `diagnostics.hashless_objects`) rather than surviving it. - 🔴 **Server-side skill delivery is not deployed.** The wire shape this store reads — kind `skill`, key `<key>:<version>`, generic payload — is what [streamer #4681](launchdarkly/streamer#4681) and [gonfalon #70638](launchdarkly/gonfalon#70638) emit; both are still open. No account can receive skill objects until they ship and the producer is enabled. - 🟡 **FDv2 is opt-in per account.** A real environment returns 403 today; the store reports it as fatal and explains what to do. - ~~🟡 **`mv` is a guess.**~~ Resolved: the request sends no `mv`. That parameter selects the *flag* data model and the connection rejects any value but the flag default, while the generic agent-skill payload is served regardless of it. The `data_model_version` constructor argument is gone with it. - 🟡 **No payload signing** on this channel yet, so Beta is TLS-only. - 🟡 **The connection also carries the environment's flags.** Skipped and counted; a transport property, not fixable here. - 🟡 **`ld-relay` does not speak the FDv2 endpoints**, so relay-only deployments cannot receive skills in Beta. **Nothing here has touched a real LaunchDarkly environment**, because it cannot yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **`FDv2SkillStore`**, a production `SkillStore` that pulls agent skills over LaunchDarkly’s SDK FDv2 **`/sdk/poll`** and **`/sdk/stream`** endpoints (stdlib HTTP, background delivery thread, stream-by-default). It implements **`SkillStore`** (`get_object`, listeners, etc.) on top of the existing protocol reader, plus **`StoreDiagnostics`**, **`wait_for_skills`**, capped backoff with **`Retry-After`**, and **`close`** that interrupts blocked socket reads so shutdown is prompt. > > **Public surface:** `FDv2SkillStore` and `StoreDiagnostics` are exported from the package; README documents production setup with `init_client` and `watch_skills`. Server-side SDK keys only; mobile/client credentials are rejected. Outages keep last-known-good content; accessors above the store are unchanged. > > **Delivery-loop fixes** bundled here: reset consecutive-failure counts on successful commits / up-to-date answers (so healthy stream recycling does not stop delivery), safe handling of non-finite **`Retry-After`**, and not treating intentional **`close`** interrupts as transport failures. Removed unused **`connect_timeout`**; **`read_timeout`** is the single knob with mode-specific defaults. > > **Tests:** in-process fake FDv2 server exercises poll/stream, basis/ETag/304, revocations, retries, timeouts, hashless payloads, and **`watch_skills`** over the transport. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 88c225e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Implements ai-sdks-monorepo #29 (§3.25, A.12), which specifies the opposite of #23 and is expected to supersede it. 422 was classified as a third thing -- neither broken nor terminal -- and that shape is a retry loop with no bound and no budget: it never moved `_failures`, so it retried at `max_backoff` for the life of the process, invisible to both `failed` and `connection_failures`. The platform chose the status to be terminal rather than us inferring it. The streamer says so at both sites that produce it -- the narrow-assignment check and the status constant -- each noting the code exists so a misconfigured SDK stops instead of hammering the fleet. Retrying forever is the precise behavior it was picked to prevent. The stated cause was also wrong, which is what made the old handling look reasonable. The gate is `payloadvers.SkillDeliveryAllowed`: delivery enabled for the account, and a credential that is not view-scoped. Every way of failing it is permanent. Skill existence is not among the causes -- with the gate open and a non-view-scoped key, the assignment path creates the agent-skill payload row lazily, so an environment holding zero skills is assigned an empty payload that commits normally through `payload-transferred`. So the message is rewritten as well as the classification. It names the two causes a reader can act on, and drops the two false claims the old text made: that the condition is about whether any skill exists, and that a skill created later arrives without a restart. It is what a customer pastes into a support ticket, so it is asserted on substance. Routing 422 through `_give_up` needed no new code and buys the right accounting: `failed` and `last_error` are set and `connection_failures` is untouched, because that counter measures consecutive *recoverable* failures against the retry bound and a fatal never retries. It also makes `wait_for_skills` return `False` immediately, through the existing `_end_delivery` release -- the observable half of the classification, so a boot gated on skills stops paying its whole timeout. Both are asserted rather than implemented. `_NoSkillPayloadError` and `diagnostics.payload_unavailable` are deleted. Both absences are asserted by name, the way §3.22 asserts its unexported constants, since a reintroduction is otherwise visible only in a log line no test reads; the diagnostics field set is pinned whole alongside. Classification is asserted to answer every status in 300..599 with exactly one of the two classes, so the forbidden shape cannot return under a different name. The `?kinds=agent-skill` declaration and the kind constants are unchanged; they already conform. README and agents.md described the deleted field and the retry behaviour, so both are rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stacked on #109 (`xie/python-skills-kinds-param`) — review that first, or read this diff alone, which touches only the 422 classification and the diagnostics field it fed. Implements ai-sdks-monorepo [#29](launchdarkly/ai-sdks-monorepo#29) (§3.25, A.12). That spec deliberately leaves this SDK non-conforming until this change lands. > [!IMPORTANT] > ai-sdks-monorepo [#23](launchdarkly/ai-sdks-monorepo#23) specifies the **opposite** — 422 as a third class, counted and retried at the backoff cap — and is expected to be closed as superseded. This PR implements #29, not #23. ## Why 422 is fatal The platform chose the status to be terminal; this SDK is not inferring it. The `streamer` repo says so in both places that produce it — `internal/fdcore/narrowassign/narrowassign.go:24` and `internal/fdcore/adapters/httpsrv/status/status.go` — each noting that SDKs treat a non-400 4xx as terminal, so a misconfigured SDK stops instead of hammering the fleet. Retrying forever is the precise behaviour the status was picked to prevent. The previous handling was a third class: neither broken nor terminal. It never moved `_failures`, so it retried at `max_backoff` for the life of the process, invisible to both `failed` and `connection_failures` — a loop with no bound and no budget. ## The stated cause was also wrong That is what made the old handling look reasonable. The gate is `payloadvers.SkillDeliveryAllowed` in gonfalon: delivery enabled for the account **and** a credential that is not view-scoped. Every way of failing it is permanent. **Skill existence is not among the causes.** With the gate open and a non-view-scoped key, the assignment path creates the agent-skill payload row *lazily*, so an environment holding zero skills is assigned an empty payload that commits normally through `payload-transferred`. Nothing here assumes gonfalon #73018 (eager row creation) lands — it is closed. So the message is rewritten alongside the classification. It names the two causes a reader can act on, and drops the two false claims the old text made: that the condition is about whether any skill exists, and that a skill created later arrives without a restart. It is what a customer pastes into a support ticket, so it is asserted on substance rather than prose. The third cause the spec lists — a typo'd `kinds` value — is deliberately **not** in the message: this SDK sends a module constant, so it is not reachable by a customer. ## What changed | File | Change | | --- | --- | | `skills_fdv2.py` | `_classify_status` returns `_FatalTransportError` for 422, in its own branch; `_NoSkillPayloadError`, the `except` block in `_run`, the `_warned_no_skill_payload` flag, and `StoreDiagnostics.payload_unavailable` are deleted | | `test_skills_fdv2.py` | Nine tests covering the new behaviour; the three that asserted the old behaviour are inverted or deleted | | `README.md`, `agents.md` | Both described the deleted field and the retry behaviour | A dedicated branch rather than folding 422 into the existing `(405, 406, 414, 501)` list: that list's `_REQUEST_ADVICE` points the reader at the base URI, which is not where the problem is. **Routing 422 through `_give_up` needed no new code**, and buys the right accounting for free — `failed` and `last_error` are set and `connection_failures` is untouched, because that counter measures consecutive *recoverable* failures against the retry bound and a fatal never retries. It also makes `wait_for_skills` return `False` immediately, through the existing `_end_delivery` release. Both are asserted rather than implemented. ## Tests 1132 pass; lint, format, and `mypy --strict` clean. Each new assertion was verified to bite, by mutating the source three ways: | Mutation | Result | | --- | --- | | 422 → recoverable | all five end-to-end 422 tests fail, plus the classification test | | 422 → bare `Exception` (a genuine third class) | shape test fails: `HTTP 422 classified as Exception, which is neither exactly recoverable nor exactly fatal` | | Reintroduce `_NoSkillPayloadError` and `payload_unavailable` | both absence tests fail | The shape test sweeps `range(300, 600)` and asserts exactly one class per status via XOR, so the forbidden shape cannot return under a different name. The two absences are asserted by name — the way §3.22 asserts its unexported constants — since a reintroduction is otherwise visible only in a log line no test reads; the diagnostics field set is pinned whole alongside. The `wait_for_skills` test asserts value *and* elapsed time against a 30s timeout. ## The one cost, from the spec When an account's gate opens, a process whose store already gave up needs a restart — nothing reopens delivery short of constructing a new store, since `close` is final and a later `start` raises. Acceptable for a beta enablement step, and deliberately preferred to retrying a permanent rejection for the life of the process. The README and `agents.md` both say so, so it is not rediscovered as a bug. ## Scope The `?kinds=agent-skill` declaration and the payload/object kind constants are unchanged — they already conform. Nothing else in the transport is touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The branch tip does not parse: the edit that shortened the 422 message dropped its closing quote, leaving an unterminated string literal in `_classify_status`. It also dropped both causes the message is asserted to name, so `test_the_422_message_names_both_of_its_real_causes` fails on the wording as well. Restored at roughly the shortened length, naming the account-level enablement, the view-scoped key and the restart that clears it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new prose restated what the neighbouring function already explains. `record_version_mismatch` keeps what differs from `record_key_mismatch` -- the extra key argument, the custom adapter it exists for -- and defers the shared rationale to it rather than arguing it a second time. Its field comments keep the constraints a future edit could break (the integer, the unchecked pin) and drop the restatement around them. Same pass over the kinds declaration and the tests that cover it: the reason a request carries `kinds` belongs on `FDV2_PAYLOAD_KIND`, and `_url` and the test docstrings point at it instead of repeating it. No behaviour change, and no assertion removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 422 message and the README both sent the reader to restart their process, and that is not what clears it. `_give_up` ends the run, not the store: only `close` sets `_closed`, and `_closed` is the only thing `start` refuses -- `start`'s own docstring says so, and `test_a_restarted_store_does_not_report_the_old_failure` has covered the general case all along. So a store that stopped on a 422 resumes in place once the account is enabled, and the message asked for a service restart instead. It says `start()` now, and the claim is pinned by a test that drives the 422, opens the gate, and starts the same store. Four more, all in the text around it: - The README said `connection_failures` "stays at zero", which is only true of a run that had no other trouble -- a 503 then a 422 leaves it at 1, since `_give_up` does not reset it. This is the correction affb82c already made once, lost in the rewrite; it now says what the handler guarantees, which is that the 422 is kept off the counter. - The new 422 prose in both files named `lastError`, `connectionFailures`, `waitForSkills`, `classifyStatus` and `FatalTransportError`, and had `waitForSkills` "resolve" -- the TypeScript spellings, against a README that lists the snake_case ones 90 lines further down. - `record_version_mismatch` wrote `served_version` through unchecked, on the reasoning `record_key_mismatch` beside it explicitly rejects: the value is store-controlled, and its integer-ness is a property of the current call order rather than of the recorder. Guarded the way `served_key` is, which is also what keeps the field an integer for the byte-comparable JSON. Reverting it logs the test's secret body verbatim. - `_FakeFDv2Endpoint.default_poll_status` was scaffolding for the superseded design, assigned by no test, and its comment described the every-request-422 retry loop this branch deleted. `agents.md`'s 422 paragraph was one 587-character line; rewrapped to the width of the section it sits in, and it now records why the message must not drift back to "restart the process". 1898 tests passing, up from 1896; lint, format and mypy clean. Each new assertion verified to bite by mutating the source back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Byte-identical to the TypeScript SDK's message now, and verified so: LaunchDarkly will not deliver Agent Skills on this connection (HTTP 422). The usual cause is a view-scoped SDK key. Check your SDK key or contact LaunchDarkly support. The account-level condition behind the remaining 422s is out. It is not a customer's setting, so naming it spent the reader's attention on something they cannot change, and the phrasing implied Agent Skills is enabled per account when what is really being described is a rollout state -- not something an error message should narrate. Those cases are unexpected from the customer's side, so they are treated as unexpected: referred to support, unenumerated. `start()` goes too, for room. The recovery is real and still documented in the README, and `test_a_store_that_gave_up_on_a_422_resumes_on_start` still pins it -- the message simply is not where it fits any more. `test_the_422_message_names_both_of_its_real_causes` becomes `..._names_its_one_actionable_cause`: the cause and the referral are asserted, and so are the four absences, so the enumeration cannot creep back. Reverting either half of the message fails it. The README and `agents.md` carried the same two-cause framing; both now say what the message says. `agents.md` explains why the message stays short without describing the condition it leaves out -- this file is public too, and the reasoning that keeps a rollout out of an error string keeps it out of a contributor guide. 1898 passing, lint/format/mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the "restart your process" overstatements, and the one with the widest reach: `_give_up` writes a single line for a 401, a 403, a 404, a 422 and an exhausted retry budget alike, and it told the operator skills would not update "until the process restarts with a working connection". A fatal ends the run and not the store -- `close` is the only thing that forecloses a restart -- so an operator who had just fixed the cause was sent to bounce a service when `start()` on the same store would do. It now names both, cheapest first, and keeps the half that was already true: the content already held stays readable. The prefix is unchanged, because `test_a_restart_during_the_give_up_still_delivers` matches on it to hold the give-up/start race open. `test_the_give_up_line_points_at_start_not_a_process_restart` asserts the line against the 401 path, so the claim is pinned for every fatal that reaches it rather than for the 422 alone. Reverting the wording fails it. `agents.md` framed this as a 422 property; it is not, so the paragraph now covers fatals generally and names the three surfaces that carry the claim -- the log line, the behaviour, and the README -- with the test pinning each. 1899 passing, up from 1898; lint, format and mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ls yet" (#109) Stacks on #94 -> #93 -> #87. Spec: [ai-sdks-monorepo#23](launchdarkly/ai-sdks-monorepo#23). TypeScript counterpart: [js-ai-sdk#87](launchdarkly/js-ai-sdk#87). Server side: [streamer#4730](launchdarkly/streamer#4730). This is the SDK half of the release blocker: other SDKs retry forever when they see a skill payload. Flag Delivery's answer is `?kinds=`, which narrows a connection to the payload kinds it declares, defaulting to `{flagging}` (so we need to opt-in to see skills). ## The declaration Every request now carries `kinds=agent-skill` — both endpoints, and on the first request as well as the ones after it, since it selects what the connection is served rather than describing what the store already holds. Without it the store receives the environment's flag payload and no skills at all, so this is a functional requirement, not for correctness/optimization. It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so `_ProtocolReader` has been warning about the second on every connection, reading only the first intent, and never adopting a basis for the flag payload — re-downloading and discarding it on every reconnect. Declaring one kind makes the connection single-payload, which is the shape the reader is built for. (That is also why declaring `flagging,agent-skill` is not the safe-looking option it appears to be.) No `mv`, still — but for a corrected reason. It selects the *flag* data model, and `objectQueryForCommand` overrides whatever a request asks for with the payload's own default for any non-flagging payload, so sending it would state a preference that is ignored. The old comment said the connection would be refused over it. 🤖 Generated with [Claude Code](https://claude.com/claude-code), edited by @XieX <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **FDv2 skill delivery** now sends `kinds=agent-skill` on every poll/stream request (with `basis` when applicable), so connections receive only the agent-skill payload instead of flags plus skills. **`HTTP 422` is treated as a fatal, non-retryable error** (aligned with the TypeScript SDK): delivery stops, `failed`/`last_error` are set, `connection_failures` is unchanged, `wait_for_skills` returns immediately, and operators are pointed at **`start()` on the same store** after fixing the cause (e.g. view-scoped SDK key) rather than only a process restart. > > **Integrity logging** gains a tenth `reason_code`, **`version_mismatch`**, via `record_version_mismatch` when a pinned version does not match what the store returned (`served_version` on the log record; **`wrong_version`** on `get_skill_result`). Like **`key_mismatch`**, it emits the **`ld.skills.integrity_failure` log only**—no product integrity signal. README/agents.md and tests cover 422, `kinds`, give-up messaging, and the write path for version mismatches. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 260856b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Resolve the one conflict, uv.lock, in favour of main: the release on main bumped the workspace packages to 0.2.3 (launchdarkly-ai-python 0.1.7), which supersedes this branch's 0.2.2/0.1.6 entries. The branch made no other lockfile change, so main's file is a complete resolution, and it matches the pyproject and release-please manifest versions that merged cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It appeared twice: once in the alphabetised type block and once in the group of closed-set unions. Drop the alphabetised entry and keep the grouped one, so the comment naming three unions still covers three. No runtime change. 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 1 potential issue.
Reviewed by Cursor Bugbot for commit 7825d8d. Configure here.
…eader The source modules had already been swept; the tests had not, and a few references and turns of phrase survived in both. - Drop the two document-section citations and the security-review pointer, keeping the technical reason each was attached to. - Say "interface" rather than "seam" across the tests, including four test names and one class name, so the tests match the modules they cover. - Reword the remaining informal phrasing: "duck-typed", "smuggle", "load-bearing", "wedged", "hammer the endpoint", "quieten". - Trim the fchmod comment to what the at_fd guard does, and drop the first-person voice from the comments that still carried it. - Fix a duplicated word in the FDv2 module docstring, capitalise one log line that started lowercase, and align skills_core.py's three outlying spellings with the rest of that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of these is already correct in the implementation, and each would have stayed correct silently: nothing failed if the behaviour went away. Verified by mutation — every test here goes red against a targeted change to the code it covers. - A boolean `manifestVersion` is not an integer. `isinstance(True, int)` is true and `True == 1`, so a range check alone reads `manifestVersion: true` as version 1 and authorizes overwrites and prunes against the entries beside it. Added to the corrupt-manifest matrix, which covers both the write-refusal and the prune paths. - A byte-identical file under a manifest entry whose key does not match the path is adopted and the entry re-keyed. The differing-bytes half of that pair was covered; this half, where adoption and the mismatched-key refusal meet, was not. - A negative `write_skills` timeout raises, with zero as the positive control so a `<= 0` guard cannot pass. - The manifest's bytes are stable across languages: two-space indent, keys sorted at every level, no trailing newline, non-ASCII escaped. Driven through a preserved unknown field, since every field the SDK writes is ASCII by construction and the rule is otherwise unreachable. - The wire object kind is not the interface kind under a new name. The two hold the same string, so neither equality nor identity can tell a coincidence from a re-export; the assertion reads each module's own definition. - The transport adds no dependency and nothing in the feature imports it, so the layering cannot invert. Asserted as an allowlist, since a denylist cannot catch the dependency nobody thought of. - The transport records none of the three signals, on a full delivery cycle and on the give-up path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`contentHash` is sha256 over the content's exact UTF-8 bytes, with no canonicalization on either side, so this SDK and LaunchDarkly's delivery service agreeing is part of the rule rather than an implementation detail. Nothing in the suite held them to it: every raw object is built by a helper that hashes its content with the same expression `verify_raw_skill` uses, so a change to the rule moved the expectation with it and nothing failed. Pins the two digests the delivery service computes, as literals, one of them over content carrying a non-ASCII character, a CRLF and no trailing newline — the three things a normalizing round trip alters without changing what the document means. Then asserts the other direction, that each of those three alterations fails the pinned digest, since agreeing on a digest proves nothing unless disagreeing is detectable. Verified by mutation: normalizing line endings, appending a final newline, or NFC-normalizing before hashing each turns one of these red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`< 0` and `<= 0` are exactly the shapes `nan` and `inf` slip through: both compare false against zero, so each passed validation and then meant something the option never promised. Guarded non-finite at the boundary instead, which is the one place it is settled once. - `write_skills(timeout=…)` made `deadline` non-finite and the bound the parameter promises silently void. `nan` is worse than unbounded, because its failure mode follows the shape of the comparison rather than the value: `now > deadline` is false, so the run reads as never expiring, while `deadline - now > 0` is also false, so the same run reads as already expired. Two implementations that each believe they bound the call can diverge in opposite directions from one input. - `FDv2SkillStore(poll_interval=…)` reached `Event.wait` unchecked, where the two values mean opposite things: `wait(nan)` returns at once, so the loop polls as fast as the endpoint will answer, and `wait(inf)` never returns, so the store polls once and then never again while reporting itself healthy. `read_timeout` beside it was already guarded this way. `timeout=0` stays valid and still means there is no time left, reported as an error action per skill rather than a raise. `-inf` was already caught by the sign half of each guard. `watch_skills`'s `debounce` needed no change — it was already correct, and its comment cited `write_skills` as the precedent it followed, which is the claim that was untrue. All four now read as one rule, and agents.md says so, so narrowing any of them back should read as breaking the others. Also adds the two inherited-name store assertions for `InMemorySkillStore`. Python satisfies them by construction — a `dict` has no prototype chain — so they are regression protection against a future change of map type rather than a live guard. Both named traps are covered: an attribute-backed map, which would answer `items` or `get` with a bound method, and a default-constructing one, which would turn an absent-key lookup into a stored entry and grow the requested set on the materialization path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trim comment blocks to the essentials, restructure public API docs as summary + bullets, drop development-history narration and internal references, and correct comments that no longer matched the code. Comment/docstring/markdown changes only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Break long single-paragraph sections into short leads and bullets, cut design justification and history narration, and correct statements that did not match the code. Headings, tables, code examples, option names, status codes and security guidance are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeffdupont
left a comment
There was a problem hiding this comment.
Review focused on GA 1.0 readiness: what would be hard or breaking to change once 1.0 freezes the API.
The implementation is careful, and the safe-fs, verification and secret-handling layers held up well. Four GA blockers are inline. Three of them come from ai-sdks-monorepo/TESTING.md and exist identically in launchdarkly/js-ai-sdk#71, so I think they want a spec PR first and then matching changes in both SDKs:
- Live changes after a
noneintent are dropped. The spec is silent here, and the reference server SDK applies them. - The skill-key grammar is stricter than the API's, so a mismatch fails the whole config.
- The transport gives up permanently after 10 consecutive failures (spec §3.25).
- Revocation doesn't reach disk for explicit request lists (spec §3.22).
I also have a list of should-fix items (a corrupt SSE event still commits the transfer, connect vs read timeout, poll ETag adopted without a commit, the watcher reconciling against the global store rather than the one it listens to, and a few more). Happy to share those too.
| if self._pending is None: | ||
| if self._intent is None: | ||
| self._intent = _INTENT_TRANSFER_CHANGES | ||
| if self._intent not in (_INTENT_TRANSFER_FULL, _INTENT_TRANSFER_CHANGES): |
There was a problem hiding this comment.
GA blocker: live changes that arrive after a none intent are dropped.
After intentCode: "none" the reader keeps the intent as none with no pending set, so a later put-object / delete-object on the same connection is discarded here. The following payload-transferred still advances the basis.
Scenario: the stream reconnects (routine), the server answers none, and then an admin revokes a skill. The delete-object is dropped and the revoked content keeps being served. Reconnecting doesn't recover it, because the basis has already moved past the change.
The reference implementation behaves the other way: the LaunchDarkly Python server SDK (ldclient/impl/datasourcev2/streaming_common.py, which calls ChangeSetBuilder.expect_changes() on none; its docstring says this "allows subsequent changes to come down the line without an explicit server intent"). Suggest leaving the reader expecting xfer-changes after none on the stream. The current tests only cover none followed by a heartbeat or goodbye.
TESTING.md (§3.25, "none leaves the set alone") doesn't cover objects that arrive after none, so this probably wants a spec line as well. js-ai-sdk #71 (skills-fdv2.ts, the INTENT_TRANSFER_NONE branch and target()) has the same behaviour.
| SKILL_KEY_GRAMMAR = "^[a-z0-9][a-z0-9-]*$" | ||
| """The skill key grammar, as quoted in rejection messages.""" | ||
|
|
||
| _SKILL_KEY_PATTERN = re.compile(r"\A[a-z0-9][a-z0-9-]*\Z") |
There was a problem hiding this comment.
GA blocker: the SDK's skill-key grammar is stricter than what the LaunchDarkly API accepts, and a mismatch fails the whole AI Config.
The REST API creates and pins skill keys under the standard LaunchDarkly key rule: letters of either case, digits, ., _ and -. So a skill keyed My_Skill or skill.v2 can be created and pinned to a variation, and then the SDK rejects the entire variation. config() / extract_variation raises, and inspect_config returns config: None with no message. That takes down the agent, not just the skill.
Fail-closed parsing is the right call for safety (it keeps a bad skills field from reading as "no skills" and pruning). But it means the two grammars have to agree before 1.0, because changing either one afterwards breaks someone. Either tighten the API to ^[a-z0-9][a-z0-9-]*$ before the feature reaches customers, or widen the SDK (which also changes the directory names write_skills produces). I haven't checked whether the UI restricts keys further; the API doesn't. js-ai-sdk #71 (types.ts SKILL_KEY_PATTERN) and the spec use the same grammar.
There was a problem hiding this comment.
Confirmed, but we need to decide if its an SDK or an API fix.
On the UI, it doesn't let you create invalid keys by either grammar because keys aren't editable, they're just kebabified names. But obviously we shouldn't rely on that for GA.
| answered = self._attempt_answered | ||
| self._reader.diagnostics.connection_failures = failures | ||
| self._reader.diagnostics.last_error = str(exc) | ||
| if failures > self._max_consecutive_failures and not repairing_state: |
There was a problem hiding this comment.
GA concern: the transport gives up permanently after 10 consecutive failures.
Every recoverable failure (5xx, 429, timeout, DNS) counts against the budget. With the default backoff it is used up after roughly 3 minutes of outage. The thread then stops, and the process gets no updates or revocations until something calls start() again.
This matches the spec (TESTING.md §3.25, "Only consecutive failures count…" / "Exceeding the consecutive-failure bound stops the transport"), so it's not an implementation bug. But shipping it in 1.0 freezes max_consecutive_failures / failed as the contract. Suggest retrying recoverable errors indefinitely with capped backoff, so that only the fatal statuses (401/403/404/422) stop the transport. That would be a spec change applied to both SDKs (js-ai-sdk #71 has the same default).
| root_path, | ||
| root_fd, | ||
| entries, | ||
| {request.key for request in requests}, |
There was a problem hiding this comment.
GA concern: revocation doesn't reach disk for an explicit request list.
The prune set is every requested key, including keys the store answered absent for. So with write_skills(skill_refs(config), root) or watch_skills(...), a skill deleted in LaunchDarkly keeps its SKILL.md on disk indefinitely, and the only signal is an error action.
That's the per-key retention TESTING.md §3.22 asks for, so it's spec-conformant. The watcher also listens only to the skill store, not to flag changes, so unpinning the skill from the variation isn't seen either.
Together, these mean the main use case in the PR description never removes a revoked skill, while the description and README say "a revoked skill's SKILL.md leaves disk without a restart". That claim holds only for "*".
Before 1.0, either decide how revocation works for explicit lists (for example, have the watcher follow config changes, or let it take a callable that supplies the current refs) or scope the README claim to "*". js-ai-sdk #71 behaves the same way.
There was a problem hiding this comment.
For now I'm going with the easy README fix. Changing how revocation works requires a decision 👍
andrewklatzke
left a comment
There was a problem hiding this comment.
Approving - reviewed individual PRs that led into this one and Christie ran local integration tests.
Worth addressing the 1.0 GA feedback pre-merge imo
knfreemLD
left a comment
There was a problem hiding this comment.
dropping approval based on Andrew's approval comment. Also reviewed all the child PRs that went into this & this change in totality

Agent Skills
Skills are versioned
SKILL.mddocuments managed in LaunchDarkly and attached to AI Config variations by reference. This adds the full server-side surface for them: the SDK reports which skills a resolved config references, retrieves their content over LaunchDarkly's FDv2 delivery channel, verifies it, and materializes it onto disk as<root>/<key>/SKILL.md— where the Claude Agent SDK and anything else following that convention discovers it.Layers
types.py/types_validation.pySkill,SkillReference,SkillOutcome,ReconcileReport; the canonical key grammar (^[a-z0-9][a-z0-9-]*$) and version predicateskills.pyInMemorySkillStoreskills_core.pySkillStoreinterface, verification, and the structured integrity log recordskills_fdv2.pyFDv2SkillStore— delivery over the SDK-facingGET /sdk/pollandGET /sdk/streamendpointsskills_fs.py/safe_fs.pyskills_watch.pywatch_skills— re-reconcile on every delivery changeDependencies flow downward only; nothing above the store can tell which store produced an object.
API
skill_refs(config)skillsarray intolist[SkillReference]. Pure — no client, store, or networkget_skill(key, *, version=None)None.version=Nonemeans newest availableget_skill_result(key, *, version=None).skill,.reason(ok/absent/integrity_failure/store_unavailable/wrong_version),.detailget_skills(refs)/all_skills()write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep")root, returning aReconcileReport. Pass"*"for the whole librarywatch_skills(skills, root, …, debounce=…)write_skillsplus re-reconcile on delivery change. Returns(initial report, SkillWatcher)SkillStoreget_object,all_objects, optional listenersInMemorySkillStore(objects=None)put(raw), for tests and bring-your-own-contentFDv2SkillStore(sdk_key, *, base_uri=…, mode="stream", poll_interval=30.0, read_timeout=None, …)start(),wait_for_skills(),close(),diagnostics,failed; also a context managerStoreDiagnosticsPlus the closed-set types (
ReconcileActionKind,OnUnavailable,SkillOutcomeReason) and the fixed on-disk constants (SKILL_FILENAME,MANIFEST_FILENAME,MANIFEST_VERSION).init_client(options={"skillStore": store})configures the store;shutdown()clears it.The primary use case
Notes for reviewers
parse_ai_confignow fails closed on askillsvalue that is not a list of{key, version}objects, where before any value parsed and was ignored. A variation carrying its own differently-shapedskillsfield must rename it before upgrading.contentHash, its key and version revalidate, and its size is within 10 MiB. There is no fallback that skips it. Every withheld skill emits one structured ERROR record —ld.skills.integrity_failure, a stability commitment, byte-identical across LaunchDarkly's AI SDKs — on the SDK's own logger, independent of telemetry configuration.write_skillstouches only what it owns. It writes<root>/<key>/SKILL.md, records what it owns in<root>/.launchdarkly-skills.json, and will overwrite or delete only manifest-recorded paths. It treats that manifest as untrusted input and re-validates every entry. The one adoption exception — a byte-identical file at a managed path — is what makes a crashed reconcile recoverable.lstat, which is a check-then-use race rather than a closed window. Write permission on the managed root or any ancestor is the security boundary on every platform, and on Windows the only one. The README's privilege-separation section is the deployment contract — the recommended shape runs the reconcile as a different identity than the agent."*".watch_skills("*", root)removes a revoked skill'sSKILL.mdwithout a restart. With an explicit list such asskill_refs(config), the list is fixed: a skill the store answersabsentfor is reported as anerrorand its files are kept, and the watcher listens only to the skill store, so unpinning a skill or moving it to a new version is not seen until the refs are read again (at the next boot, typically). Making the watcher follow config changes is a planned follow-up and is additive.write_skillsblocks. It isasyncfor parity with the other accessors and with the TypeScript SDK, but awaits nothing. Wrap it inasyncio.to_threadif a large reconcile holding the event loop matters.ld-relaydoes not speak the FDv2 endpoints.Testing
403 skills tests across five files —
test_skills(125),test_skills_fdv2(139),test_skills_fs(107),test_safe_fs(22),test_skills_watch(10) — including a filesystem abuse matrix and root-swap race tests that fail outright rather than skip.🤖 Generated with Claude Code, reviewed by @XieX.
Note
Overview
Adds Agent Skills to
launchdarkly-ai-server: versionedSKILL.mdcontent tied to AI Config variations, fetched through an injectableSkillStore, verified (sha256, key/version grammar, size cap), and materialized under<root>/<key>/SKILL.mdwith manifest-scopedwrite_skills/watch_skills.New public surface includes
skill_refs,get_skill/get_skill_result,get_skills/all_skills,InMemorySkillStore,FDv2SkillStore(poll/stream delivery), integrity logging onld.skills.integrity_failure, and types such asSkill,ReconcileReport.init_client(options={"skillStore": …})wires the store (re-applied on later inits);shutdown()clears it.Breaking:
parse_ai_confignow fail-closed validates an optionalskillsarray of{key, version}(including rejectingskills: null); malformed values reject the whole variation instead of being ignored.Implementation splits across
skills_core(verification, outcomes, telemetry seam),skills_fs+safe_fs(descriptor-pinned atomic writes, prune only on manifest paths), and docs in README/agents.md covering privilege separation and POSIX vs Windows bounds.Reviewed by Cursor Bugbot for commit c547be2. Bugbot is set up for automated code reviews on this repo. Configure here.