Skip to content

feat(client): Agent Skills — umbrella for the #50–#54 split - #45

Closed
XieX wants to merge 1 commit into
mainfrom
xie/agent-skills-feature-ac9ac7
Closed

XieX wants to merge 1 commit into
mainfrom
xie/agent-skills-feature-ac9ac7

Conversation

@XieX

@XieX XieX commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

📋 This PR is now the umbrella — review the stack below

The change was split into five stacked PRs, small enough to actually read. This branch is deliberately untouched and will not be force-pushed, so every review comment below keeps its anchor. Review #50 first; each PR's diff is against the one before it.

# PR What it adds + / −
1 #50 Reference discovery — the types, config validation, frontmatter, skill_refs +810 / −15
2 #51 Retrieval — the store seam, integrity verification, telemetry seam, accessors +2,188 / −20
3 #52 safe_fs.py — descriptor-pinned filesystem primitives +604
4 #53 Materialization — write_skills, the manifest, reconcile +1,879 / −26
5 #54 The filesystem abuse matrix (tests only) +1,057 / −7

main#50#51#52#53#54

The tip of the stack has the same tree as this branch, plus the changes for review findings 1, 2 and 3 and 22 new direct unit tests for safe_fs. make lint, make format-check, mypy packages/*/src and the full test suite are clean on every slice, not just the tip.

Where the review findings landed

Finding Status Where
1 — version pinning cannot be satisfied by the store seam (blocking) Resolved #51
2 — contentHash mandatory here, optional on the wire Resolved (SDK half) #51, one call site in #53
3 — SKILL_OBJECT_KIND may not match the delivery kind Resolved #51
4 — write_skills is async but fully synchronous ⏸ Deferred code in #53
5 — unavailability semantics differ between all_skills() and write_skills("*") ⏸ Deferred code in #51 / #53
6 — nothing enforces one reconcile at a time ⏸ Deferred code in #53
7 — Skill / SkillReference accept invalid values from direct callers ⏸ Deferred code in #50
8 — silent omissions in get_skills 🟡 Partly addressed by finding 2's WARN summary #51
9 — frontmatter() depends on a dev-only dependency ⏸ Deferred — a deliberate design decision, not an oversight code in #50
10 — idiomatic / cleanliness items ⏸ Deferred #50, #51, #53
11 — test gaps 🟡 The finding-1 gap is closed in #51; the rest deferred #51, #54

Finding 1 — the fix

Version is now part of the lookup identity rather than a filter applied to the answer: SkillStore.get_object(kind, key, version=None), where None means "the newest you hold". The post-fetch equality check stays, but as a defense against an untrusted store answering with the wrong object rather than as the selection mechanism. InMemorySkillStore keys by (key, version) and holds both; all_objects returns one entry per (key, version) under keys documented opaque, with newest_by_key as the single place that collapses a whole-store read to one object per key.

TestVersionPinning holds two versions of one key and asserts a pinned-old lookup, a latest lookup, both against one store, a mixed batch, and all_skills() returning one entry per key. Reverting only the seam change fails three of them, so they are not vacuous.

Finding 2 — the fix

contentHash stays mandatory; that default is right. What changed is the signalling: a run that withheld anything logs one WARN naming the counts, and a run where nothing verified says so explicitly and names contentHash — previously that returned an empty result indistinguishable from "this project has no skills".

Still open on the delivery side: whether the wire object needs a hashing-algorithm discriminator rather than a bare hex string. The SDK assumes SHA-256 lowercase hex. That is a wire-contract decision rather than an SDK one, so it is not resolved in the stack.

Finding 3 — the decision

SKILL_OBJECT_KIND keeps its value but is no longer exported from the package root. It is the string the SDK hands a store, and an adapter maps whatever the transport underneath calls a skill onto it; publishing it would advertise an SDK-side seam value as the wire contract — a claim this side cannot make, and hard to walk back once a caller depends on it. test_object_kind_is_not_public_api asserts it is absent from both __all__ and the package namespace, and it stays reachable through skills_core for the adapter that needs to agree with it.

This deliberately does not pick a kind/category string, so it needs no confirmation from the delivery side to land.

⚠️ Two cross-language items

Findings 1 and 3 both change a seam the TypeScript SDK mirrors. The strings are untouched, but the shape is not:

  • get_object(kind, key, version) — the protocol needs the same third parameter in TypeScript.
  • SKILL_OBJECT_KIND should be un-exported there too, for symmetry.

Also worth knowing

make typecheck (mypy ., including tests) fails on main already, with Duplicate module named "conftest" (packages/ai/tests vs packages/claude-agents/tests), which aborts before checking anything. Pre-existing and unrelated to this work; mypy packages/*/src is the gate every slice passes. Worth a separate fix with --explicit-package-bases or an __init__.py.


Original description

Adds support for Agent Skills: versioned SKILL.md documents managed in LaunchDarkly and attached to Config variations by reference. The SDK surfaces which skills a config references, retrieves their content, and materializes them onto disk where agent runtimes discover them.

Public API

Export Description
skill_refs(config) Project a config's skills array into list[SkillReference]. Pure — no client, store, or network.
get_skill(key, *, version=None) One verified skill, or None. version=None means newest available.
get_skills(refs) Batch form; accepts SkillReference values and bare key strings.
all_skills() Every verified skill the store holds.
write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep") Materialize under root, returning a ReconcileReport.

New types: Skill, SkillReference, ReconcileAction, ReconcileReport, plus the ReconcileActionKind and OnUnavailable unions and the fixed on-disk / on-the-wire constants — all exported from the package root.

How content arrives

Content comes through an injectable SkillStore seam — get_object(kind, key), all_objects(kind), and an optional add_listener(kind, fn) — configured with init_client(options={"skillStore": store}). InMemorySkillStore ships for local development and testing.

The LaunchDarkly delivery transport is a follow-up release and drops in behind the same seam with no public API change.

Verification

Content is verified before it reaches caller code: key and version are revalidated, size is bounded, and the sha256 of the verbatim UTF-8 bytes must match the delivered contentHash. Anything that does not verify is withheld and treated as missing, so no unverified content is ever returned.

Materialization

write_skills writes <root>/<key>/SKILL.md and records what it owns in a manifest, so it overwrites or removes only files it wrote — a file you placed yourself is reported and left untouched. Writes are atomic, at mode 0644. Pruning removes formerly-managed skills that are no longer referenced, which is how revocation works. Every outcome is visible in the returned ReconcileReport (.actions, .ok, .errors).

write_skills performs synchronous filesystem I/O and does not yield — it is async for signature parity with the other accessors. Wrap it in asyncio.to_thread if that matters on your loop.

Notes for reviewers

  • Skill.frontmatter() needs pyyaml, which is deliberately not a runtime dependency of this package. It returns None rather than raising when the library is absent or the block cannot be parsed.
  • examples/skills_example.py runs the whole path end to end, fully offline — no SDK key, no API key, no sockets.
  • This branch forked from an older main and is behind it; it needs a rebase before merge.

Testing

uv run pytest packages/ → 856 passed. ruff check, ruff format --check, and mypy packages/*/src all clean.

🤖 Generated with Claude Code

@XieX
XieX force-pushed the xie/agent-skills-feature-ac9ac7 branch from 7f31f9e to f6c9cd2 Compare August 24, 2026 18:43
@XieX
XieX requested a review from knfreemLD August 24, 2026 18:44
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Code review — Agent Skills (discovery, retrieval, materialization)

Reviewed against the internal SDK design page (SDK Design: Agent Skills Integration (Python/Node), PD/5215649997) and the delivery-side contract in AIC-2905. Overall this is unusually careful work: the filesystem layer's descriptor-pinned writes, manifest ownership model, prune suppression on incomplete retrieval, and telemetry redaction are all right, and the test suite (163 tests) covers the security surface well. Findings below are ordered by severity.

1. Version pinning cannot be satisfied by the store seam (spec inconsistency, blocking)

The spec: "Version selection is by pin (attached) or latest (standalone) … the payload contains every referenced skill in the project plus the newest version of each skill." AIC-2905 confirms delivery: the payload is the union of the latest version of every skill and every version currently referenced by any variation attachment. So multiple versions of the same key coexist in one payload.

But the store seam is keyed by key alone:

class SkillStore(Protocol):
    def get_object(self, kind: str, key: str) -> dict[str, Any] | None: ...

and resolve_from_store fetches one object, then compares versions after the fact:

raw = store.get_object(SKILL_OBJECT_KIND, key)
...
if wanted_version is not None and skill.version != wanted_version:
    return Resolution(error=...)

InMemorySkillStore likewise stores one object per (kind, key). Consequence: a variation pinned to an older version resolves the newest object and then rejects it — get_skill("k", version=1) returns None, get_skills([...]) silently omits it, and write_skills reports it as unresolvable. Pinning only works when the pin happens to equal the newest version, which is exactly the case the tests exercise (test_version_omitted_returns_newest_available and test_exact_version_match_returns_skill each put a single object).

Suggested fix: make version part of the lookup identity — either get_object(kind, key, version) / a get_skill_object(key, version | None) method on the protocol, or an agreed composite object key ("<key>:<version>" with a key-only alias for latest). Whatever is chosen needs to match the real FDv2 store adapter, and a test needs to hold two versions of one key simultaneously and assert both a pinned-old and a latest lookup succeed.

2. contentHash is mandatory here but optional on the wire

verify_raw_skill withholds any object without a string contentHash, and that is the correct default. But AIC-2905 records that ContentHash was dropped from delivery PR 2 and returns in PR 6 as an additive omitempty field, pending the security review's hashing-algorithm decision. Against a payload built before that lands, every skill fails verification and the whole feature silently returns nothing — the only signal is a log line plus an integrity signal. Worth (a) confirming the delivery/SDK sequencing so this can't ship in that order, and (b) making a total-withholding run louder than a per-object debug log (e.g. one WARN summarizing "N of M skills withheld").

Related: the SDK assumes SHA-256 hex lowercase (test_uppercase_hash_rejected) while the algorithm decision is still open server-side. If there is any chance of an algorithm change, the wire object needs an algorithm discriminator rather than a bare hex string.

3. SKILL_OBJECT_KIND = "skill" doesn't match the FDv2 kind

The constant is public API and documented as wire-shaped and "identical across language implementations", but the delivery decision in AIC-2905 is Kind: "inline-resource" + Category: "skill" (flag/segment keep bare Kind). Either the store adapter maps ("inline-resource", "skill") → "skill" (then this constant is an SDK-internal seam value and the docstring overstates it), or the constant is simply wrong. Please reconcile before the export is public, since it's hard to walk back.

4. write_skills is async but fully synchronous

Every path — hashing, os.stat, atomic_write, fsync, os.replace, manifest rewrite — runs inline on the calling event loop, and timeout is enforced only by time.monotonic() >= deadline checks between steps, so a single slow write (NFS, a full disk) cannot be interrupted. For a boot-time API this is mostly tolerable, but the signature promises otherwise. Options: keep the async signature and wrap the synchronous core in asyncio.to_thread, or expose a sync write_skills_sync and make the async one a thin wrapper. Either way, the docstring should state that timeout bounds progress between filesystem operations, not any individual one.

Spec note in the same area: §startup resilience says "the timeout bounds the whole call including retrieval; retries are bounded inside it", whereas the README states this layer performs no retries. One of the two should change.

5. Inconsistent unavailability semantics between all_skills() and write_skills("*")

async def all_skills() -> list[Skill]:
    objects, error = list_raw_objects(require_store())
    if error is not None:
        return []

write_skills("*") deliberately distinguishes "store raised" from "store is empty" and suppresses pruning; all_skills() collapses both into []. The natural composition await write_skills(await all_skills(), root) therefore prunes the entire managed root on a transport outage — precisely the data loss _resolve_all was written to prevent. Either propagate the failure from all_skills() (raise, or return a result type), or document the hazard prominently and steer callers to write_skills("*"). There is also no test for all_objects raising in all_skills(); every other error path here has one.

6. Nothing enforces "one reconcile at a time"

The module documents that concurrent reconciles against the same root can interleave and lose manifest entries, leaving files unmanaged, but nothing prevents it — and thread-wrapped calls are the natural workaround for finding #4. A module-level asyncio.Lock keyed by resolved root (plus, if cross-process matters, an O_EXCL lockfile inside the root) is cheap insurance. No test covers concurrent reconciles today.

7. Skill / SkillReference accept invalid values from direct callers

Both are frozen dataclasses with no __post_init__ validation, so Skill(key="../../etc", version=-1, content=..., content_hash="") constructs fine. The filesystem layer re-validates (good — test_hostile_key_is_rejected), but write_skills([Skill(...)]) bypasses hash verification entirely for caller-supplied skills, and accessors trust store-produced values only. Given Skill is exported and the docs show constructing it for local development, add __post_init__ validation (key grammar, version >= 1, hash shape, size) or verify caller-supplied skills on the write path.

8. Silent omissions in get_skills

Missing, version-mismatched, and integrity-failed references are all dropped from the returned list with no way for a caller to tell which, or how many. For an API whose failure mode is "the agent silently loses a capability", consider returning references alongside skills (or a companion errors list), and at minimum log at WARN rather than debug.

9. Skill.frontmatter() depends on a dev-only dependency

PyYAML is a dev dependency, import yaml is lazy, and every failure — including ImportError — degrades to None, which is indistinguishable from "this skill has no frontmatter". In a production install frontmatter() therefore always returns None, and test_returns_none_when_yaml_unavailable locks that in as intended behavior. Either add an optional extra (launchdarkly-ai-server[frontmatter]) and raise a clear error when it's absent, or hand-roll the tiny scalar-mapping parser the frontmatter contract actually needs and drop the dependency.

10. Non-idiomatic / cleanliness

  • parse_block defines class _BoundedSafeLoader(yaml.SafeLoader) inside the function, so a new class object is built per call, and _depth is a class attribute mutated as if it were instance state (fine only because each call gets a fresh class — a latent trap if the class is ever hoisted naively). Hoist it to module scope with __init__-initialized instance state, behind a module-level lazy import.
  • Skill.frontmatter() does a function-local import to dodge a cycle. A free function (parse_frontmatter(skill)) in frontmatter.py would keep types.py a pure data module and satisfy the "imports at the top" convention.
  • Inconsistent argument-error types: bare string → TypeError in get_skills, ValueError in write_skills; and "*" is meaningful in one and not the other. Pick one convention.
  • SKILL_KEY_GRAMMAR = "^[a-z0-9][a-z0-9-]*$" is exported while the compiled matcher uses \A…\Z. The public constant isn't the pattern actually enforced (^…$ admits a trailing newline); derive one from the other.
  • _write_all catches only OSError, while store-facing code catches Exception. A non-OSError escaping the write loop (a custom Skill subclass, an encoding surprise) aborts the whole reconcile mid-run and skips the manifest rewrite, leaving disk and manifest divergent. Catch Exception there and turn it into an error action.
  • ReconcileReport is frozen=True but holds a mutable list, and ok/errors rebuild a list on each access. tuple[ReconcileAction, ...] plus any(...) for ok is both cheaper and actually immutable. Similarly, ReconcileAction permits nonsense combinations (action="written" with error=...); a __post_init__ assertion or separate types would make the contract self-enforcing.
  • Duplicate references ([SkillReference("a", 1), "a"], or two attachments pinning different versions of one key) are neither deduped nor rejected: config validation allows them, skill_refs returns them verbatim, get_skills fetches twice, and write_skills writes the same path twice, reporting two actions. Dedupe by key (and decide what a conflicting double-pin means) — today it's last-write-wins by list order.
  • _parse_skills validates only key and version and ignores unknown fields in each entry. That's probably right for forward compatibility, but it should be a stated decision rather than an omission.

11. Test gaps

Coverage is strong; these are the holes I'd want closed:

  • Two versions of the same key in the store, with a pinned-old lookup (finding feat: initial commit — LaunchDarkly AI SDK for Python #1) — currently unrepresentable, which is the point.
  • all_objects raising under all_skills() (finding fix: set bootstrap-sha to pre-migration commit #5).
  • Duplicate references across skill_refs, get_skills, write_skills.
  • Concurrent write_skills against one root (finding chore: release main #6).
  • A non-OSError raised inside the write loop, and a manifest-rewrite failure after successful writes (partial-state behavior isn't asserted anywhere).
  • Frontmatter with CRLF line endings and with a leading UTF-8 BOM — both are realistic for SKILL.md authored on Windows, and both currently fall out as "no frontmatter".
  • write_skills([Skill(...)]) with a caller-constructed skill whose content_hash doesn't match content (finding Revert "chore: release main" #7).

Merge confidence

Moderate-to-high on the filesystem and telemetry layers — I'd merge those largely as-is. Low on the retrieval layer until finding #1 is resolved, since version-pinned references are the primary use case and the current store seam cannot express them; findings #2 and #3 also need a decision recorded against the delivery side before the constants and required fields become public API.

@knfreemLD

Copy link
Copy Markdown

Sent off a Devin PR here mostly around correctness and idiomatic code; I think points 1 and 3 we should look to resolve here, and point 2 I believe is a quick enough fix. Everything else I believe is lower priority or quick fixes

Adds support for Agent Skills: versioned `SKILL.md` documents managed in
LaunchDarkly and attached to AI Config variations by reference. The SDK
surfaces which skills a config references, retrieves their content, and
materializes them onto disk where agent runtimes discover them.

Public API, all exported from the package root:

- `skill_refs(config)` projects a config's `skills` array into
  `list[SkillReference]`. Pure — no client, store, or network.
- `get_skill(key, *, version=None)` returns one verified skill, or None.
- `get_skills(refs)` is the batch form, accepting references and bare keys.
- `all_skills()` returns every verified skill the store holds.
- `write_skills(skills, root, *, prune=True, timeout=10.0,
  on_unavailable="keep")` materializes under `root`, returning a
  `ReconcileReport`.

New types: `Skill`, `SkillReference`, `ReconcileAction`, `ReconcileReport`,
the `ReconcileActionKind` and `OnUnavailable` unions, and the fixed on-disk
/ on-the-wire constants.

Content arrives through an injectable `SkillStore` seam —
`get_object(kind, key)`, `all_objects(kind)`, and an optional
`add_listener(kind, fn)` — configured with
`init_client(options={"skillStore": store})`. `InMemorySkillStore` ships for
local development and testing. The LaunchDarkly delivery transport drops in
behind the same seam with no public API change.

Store data is untrusted. Key and version are revalidated, size is bounded,
and the sha256 of the verbatim UTF-8 bytes must match the delivered
`contentHash`; anything that does not verify is withheld and treated as
missing, so no unverified content is ever returned. Content carrying an
unpaired surrogate has no UTF-8 encoding at all and is withheld too —
`str.encode` is called strictly, never with an error handler that would
fabricate bytes a hash comparison could then accept.

Integrity failures are reported through a private telemetry seam carrying
hashes and byte counts only, never the skill body. The two properties
copied off the wire, `skill_key` and `expected_hash`, are shape-checked and
replaced when malformed, so a hostile store cannot use either one to
publish the body through a signal that is otherwise body-free.

`write_skills` writes `<root>/<key>/SKILL.md` and records what it owns in a
manifest, so it overwrites or removes only files it wrote — a file you
placed yourself is reported and left untouched. Writes are atomic, at mode
0644, and every destructive step runs against a descriptor pinned to a
directory that was already checked, so a path swapped after the check
cannot redirect a write or an unlink out of the managed root. Where the
platform has no `*at()` family (Windows) the identical sequence runs
against full paths. Pruning removes formerly-managed skills that are no
longer referenced, which is how revocation takes effect.

`timeout` bounds retrieval, the writes, and pruning; only the final
manifest rewrite runs past it, so files already written are never orphaned.
`write_skills` performs synchronous filesystem I/O and does not yield — it
is `async` for signature parity with the other accessors. Reconcile one
root at a time: a run is atomic against the rest of the loop today, so
wrapping it to run concurrently makes two runs against one root race on the
manifest.

Note two behaviour changes for existing users:

- `parse_ai_config` now fails closed on a `skills` value that is not a list
  of `{key, version}` objects, where before any value parsed and was
  ignored. A variation carrying its own differently-shaped `skills` field
  must rename it before upgrading.
- `shutdown()` clears the configured skill store along with the client.

`Skill.frontmatter()` needs `pyyaml`, which is deliberately not a runtime
dependency of this package. It returns None rather than raising when the
library is absent or the block cannot be parsed. `agents.md` records this
alongside the rest of the package's dependencies and why each sits where it
does.

Every security guard has a test that fails if the guard is removed. Two are
worth naming, because the obvious test does not reach them. The
unencodable-content cases pin `contentHash` to the sha256 of the bytes a
non-strict encoder would have fabricated, since an arbitrary wrong hash is
rejected by the mismatch check first and never exercises the encoder guard.
The redaction cases smuggle the body through `contentHash` and through
`key`, since a sweep using a well-formed digest under a valid key reaches
neither replacement branch.

Testing: `uv run pytest` → 1333 passed, 11 skipped. `ruff check`,
`ruff format --check`, and `mypy packages/*/src` all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@XieX
XieX force-pushed the xie/agent-skills-feature-ac9ac7 branch from 9570e8d to 02aeaa4 Compare August 25, 2026 19:17
@XieX XieX changed the title feat(client): Agent Skills — discovery, retrieval, and materialization feat(client): Agent Skills — umbrella for the #50–#54 split Aug 25, 2026
@XieX

XieX commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Split this into five stacked PRs so each one is small enough to read. This branch is untouched and will not be force-pushed — the review above keeps every one of its anchors, and this PR becomes the umbrella.

main#50#51#52#53#54

# PR What it adds + / −
1 #50 Reference discovery — types, config validation, frontmatter, skill_refs +810 / −15
2 #51 Retrieval — store seam, integrity verification, telemetry seam, accessors +2,188 / −20
3 #52 safe_fs.py — descriptor-pinned filesystem primitives +604
4 #53 Materialization — write_skills, manifest, reconcile +1,879 / −26
5 #54 The filesystem abuse matrix (tests only) +1,057 / −7

Per @knfreemLD's triage, findings 1, 2 and 3 are resolved in the stack; the rest are recorded against the slice whose code they touch.

Finding Status PR
1 — version pinning cannot be satisfied by the store seam (blocking) ✅ Resolved #51
2 — contentHash mandatory here, optional on the wire ✅ Resolved (SDK half) #51, one call site in #53
3 — SKILL_OBJECT_KIND doesn't match the delivery kind ✅ Resolved #51
4 — write_skills async but fully synchronous ⏸ Deferred code in #53
5 — all_skills() vs write_skills("*") unavailability semantics ⏸ Deferred code in #51 / #53
6 — nothing enforces one reconcile at a time ⏸ Deferred code in #53
7 — Skill / SkillReference accept invalid values from direct callers ⏸ Deferred code in #50
8 — silent omissions in get_skills 🟡 Partly addressed by finding 2's WARN summary #51
9 — frontmatter() on a dev-only dependency ⏸ Deferred — a deliberate design decision code in #50
10 — idiomatic / cleanliness ⏸ Deferred #50, #51, #53
11 — test gaps 🟡 The finding-1 gap is closed in #51 #51, #54

Finding 1. Version is now part of the lookup identity rather than a filter applied afterwards: get_object(kind, key, version=None), where None means "the newest you hold". The post-fetch equality check stays, but as a defense against an untrusted store answering with the wrong object rather than as the selection mechanism. InMemorySkillStore keys by (key, version) and holds both; all_objects returns one entry per (key, version) under keys documented opaque, and newest_by_key is the single place that collapses a whole-store read to one object per key — so write_skills("*") cannot write <root>/<key>/SKILL.md twice in one run. TestVersionPinning holds two versions of one key and asserts both a pinned-old and a latest lookup succeed; reverting only the seam change fails three of its tests, so they are not vacuous.

Finding 2. contentHash stays mandatory — that default is right. The signalling changed: a run that withheld anything logs one WARN naming the counts, and a run where nothing verified says so explicitly and names contentHash. Still open on your side: whether the wire object needs a hashing-algorithm discriminator rather than a bare hex string. That is a wire-contract decision, not an SDK one, so the stack doesn't decide it.

Finding 3. Took the first branch you offered: the constant keeps its value but is no longer exported from the package root, and its docstring now says what it actually is — the string the SDK hands a store, which an adapter maps ("inline-resource", "skill") onto. That drops the public commitment that was hard to walk back without locking in a string this side can't confirm, and it needs no decision from delivery to land. test_object_kind_is_not_public_api asserts it's absent from both __all__ and the package namespace.

⚠️ Both findings 1 and 3 change a seam the TypeScript SDK mirrors. The strings are untouched, but the shape is not: get_object needs the same third parameter there, and SKILL_OBJECT_KIND should be un-exported for symmetry.

One unrelated thing found on the way: make typecheck (mypy ., including tests) already fails on main with Duplicate module named "conftest" (packages/ai/tests vs packages/claude-agents/tests), which aborts before checking anything. Every slice passes mypy packages/*/src, the gate this PR's own commit message cites. Worth fixing separately.

@XieX

XieX commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of the split, see PR description for links to the stacked PRs.

@XieX XieX closed this Aug 26, 2026
XieX added a commit that referenced this pull request Sep 15, 2026
**PR 5 of 5** splitting draft #45 for review. Stacked on #53. This is
the tip of the stack — its tree is #45's, plus the changes for review
findings 1, 2 and 3 and the new `safe_fs` unit tests.

`main` ← #50#51#52#53 ← **`split/skills-fs-hardening`**

> [!NOTE]
> **Aug 28:** rebased in place for the stack-wide bytes pivot
(`Skill.content: bytes`, `frontmatter()` deleted). Test helpers here
construct `Skill` values from encoded bytes now; the defenses under test
are unchanged. This branch also carries the rename-containment assertion
fix cherry-picked across the rebase.

## What's here

Tests only, no source. The adversary: every filesystem defense PRs 3 and
4 introduced now has a test that fails if the defense is removed.

- **Path traversal** — a key that escapes the root, a key that is the
manifest filename, an over-long key, and a key that resolves outside
after `realpath`. Each refused before any filesystem call, and the
containment check asserted on inode identity rather than on path
strings.
- **Symlink attacks** — a symlinked skill directory, a symlinked target
file, and `<root>/<key>` swapped for a symlink at the exact instant of
the rename and of the unlink. That last one is the narrowest possible
version of the window descriptor pinning exists to close, fired from the
interception point (`os.replace` / `os.unlink`) rather than from
implementation internals.
- **The no-`*at()` shape** — the path fallback Windows takes for every
operation, exercised with the capability probe forced off, so the
platform that cannot pin a descriptor is not the untested one. The
TOCTOU tests skip off that *same* flag deliberately: a probe that
wrongly reported "unsupported" must not also silently skip the tests
that would have caught it.
- **Non-regular files and clobber protection** — a fifo or a directory
where `SKILL.md` belongs, and a file at a managed path with no matching
manifest entry: reported and left alone, never overwritten and never
removed.
- **Corrupt manifests** — unreadable, unparseable, not an object,
malformed entries, and a `manifestVersion` this release cannot read. No
overwrites, no prunes, an `error` action naming the manifest, and the
manifest itself left as it was found.
- **Atomicity** — a crash injected between the write and the rename
leaves neither a partial file nor a temp file, and the one recorded
rename is proved to have moved `SKILL.md` within the target's *own*
directory. Where the platform has `renameat` that is asserted by
descriptor identity (`src_dir_fd == dst_dir_fd`, resolving to the skill
directory's `(st_dev, st_ino)`), which is stronger than comparing path
strings because it also rules out the descriptor having been redirected
between the check and the rename.
- **Telemetry** — the three signal names are asserted as an **allowlist,
not a floor**: any other name reaching the emitter fails the test. The
two deliberately excluded names are called out explicitly rather than
left to the subset check. No signal carries a filesystem path or the
skill body, an emitter that raises never fails the reconcile, and
`client.track()` is never reached.

## Notes for reviewers

Two cases are worth naming, because the obvious test does not reach the
guard:

- The **unencodable-content** cases pin `contentHash` to the sha256 of
the bytes a non-strict encoder *would have fabricated*. An arbitrary
wrong hash is rejected by the mismatch check first and never exercises
the encoder guard at all, so a naive test here passes against an
implementation that reaches for `errors="surrogatepass"`.
- The **redaction** cases smuggle the body through `contentHash` and
through `key`. A sweep using a well-formed digest under a valid key
reaches neither replacement branch.

The set of tests in `test_skills_fs.py` at this tip matches #45's modulo
the bytes pivot (helpers build `Skill.content` as `bytes`; the deleted
`frontmatter()` tests were removed in PR 1) — nothing else was dropped
in the split. Two classes are reordered, which is why the file shows
more diff churn than the content change warrants.

## Testing

`uv run pytest` → 1280 passed. `ruff check`, `ruff format --check`, and
`mypy packages/*/src` all clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Tests only** — expands `test_skills_fs.py` so every filesystem
hardening behavior from earlier stack PRs has a regression test in one
place (no implementation changes).
> 
> The module gains interception helpers (`_ReplaceSpy`,
`_SwapDirectoryDuring`, inode-based `_assert_atomic_rename_of`) to
assert SDK validation blocks writes before `os.replace`, and to simulate
rename/unlink races and injected rename failures.
> 
> New coverage includes **atomicity** (0644, single in-directory rename,
no partial files on failure), a parametrized **path traversal** matrix
(hostile keys must not attempt `SKILL.md` renames), **symlink** refusal
and descriptor-pinned TOCTOU cases (skipped when `SUPPORTS_DIR_FD` is
false), a forced **no-`*at()`** Windows-shaped fallback,
**FIFO/non-regular** paths, **clobber protection**, **corrupt manifest**
fail-closed behavior, and **telemetry** allowlists
(materialized/revoked/integrity only, no paths or bodies, manifest
version redaction, integrity property parity across accessor vs write
paths).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c88d5bf. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
XieX added a commit that referenced this pull request Sep 15, 2026
…4/5) (#53)

**PR 4 of 5** splitting draft #45 for review. Stacked on #52.

`main` ← #50#51#52 ← **`split/skills-materialization`** ←
`split/skills-fs-hardening`

> [!IMPORTANT]
> **Aug 28 repivot: skills are now opaque byte buffers by
construction.** `Skill.content` is `bytes` (the verified verbatim bytes,
exactly what was hashed), the `frontmatter()` convenience accessor and
`frontmatter.py` are deleted, and the SDK no longer parses or interprets
skill content anywhere. Consumers who want frontmatter parse it
themselves. The stack was rebased in place to make each change in the
slice that introduced the code; the TypeScript SDK is getting the mirror
change (`content: Uint8Array`) separately.

## What's here

`write_skills` writes `<root>/<key>/SKILL.md` and records what it owns
in a manifest at `<root>/.launchdarkly-skills.json`, so it overwrites or
removes **only** paths that manifest records. A file you placed yourself
is reported and left untouched.

```python
report = await write_skills(refs, ".claude/skills")
for action in report.errors:
    print(f"skill {action.key or '<run>'}: {action.error}")
```

`skills` accepts `Skill` values, references, bare keys, or the literal
`"*"` for everything the store holds. Every outcome is visible in the
returned `ReconcileReport`: one `ReconcileAction` per skill carrying
`written`, `updated`, `skipped_current`, `removed` or `error`, plus
`.ok` and `.errors`. A failure belonging to the run rather than to one
skill — an unreadable manifest, a retrieval that failed before any key
was known — carries the **empty string** as its key; callers grouping a
report by key need to expect that sentinel.

New exports: `write_skills`, `ReconcileAction`, `ReconcileReport`,
`ReconcileActionKind`, `OnUnavailable`, `SKILL_FILENAME`,
`MANIFEST_FILENAME`, `MANIFEST_VERSION`.

## The defenses

Writes are atomic, at mode `0644`, and every destructive step runs
against a descriptor pinned to a directory that was already checked, so
a path swapped after the check cannot redirect a write or an unlink out
of the managed root. Where the platform has no `*at()` family the
identical sequence runs against full paths.

- The key is re-validated here regardless of upstream validation, before
any filesystem call, because a key becomes a directory name. The data
model allows 256 characters and `NAME_MAX` is 255 bytes, so an over-long
key is refused too.
- Never write or unlink through a symlink, on the write path or the
prune path.
- Destruction only on manifest-listed paths whose `key` matches.
- A corrupt manifest fails closed: no overwrites and no prunes,
brand-new paths may still be written, an `error` action names the
manifest, and the manifest is not rewritten.
- An incomplete retrieval suppresses pruning, so a transport outage
cannot read as "everything was revoked".
- Content is re-verified immediately before the write, because a `Skill`
can also be constructed directly by a caller. With the bytes pivot, this
pass hands `Skill.content: bytes` to the same `verified_bytes` the
accessors use, which hashes the bytes directly — same two-pass design,
identical telemetry property keys.

Pruning removes formerly-managed skills that are no longer referenced —
that is how revocation takes effect. `timeout` bounds retrieval, the
writes and the pruning; only the final manifest rewrite runs past it, so
files already written are never orphaned.

The `"*"` form collapses to one object per key at its newest version,
since `<root>/<key>/SKILL.md` is a single path and writing it twice in
one run is a bug rather than a policy. It reports a withholding count at
WARN for the same reason the accessors do (finding 2).

## Notes for reviewers

- **`write_skills` blocks.** It is `async` for parity with the other
accessors and with the TypeScript SDK, but it awaits nothing: every
read, write, `fsync` and rename runs inline. Wrap it in
`asyncio.to_thread` if that matters on your loop. For the same reason
`timeout` is checked *between* steps rather than interrupting one
already in progress. Reconcile one root at a time — because nothing
yields today a run is atomic against the rest of your loop, and wrapping
it to run concurrently makes two runs against one root race on the
manifest. (Review findings 4 and 6 on #45 both live here and are tracked
there, not resolved in this PR.)
- **The root's parent must exist.** `write_skills` creates the root
itself but never its ancestors, so a typo cannot scatter a directory
tree across a project. An absent parent, a root that is an existing
file, and a root that is a symlink each raise `ValueError` — caller
errors, distinct from the per-skill `error` actions in the report.
- **The security abuse matrix is PR 5.** Every guard listed above is in
this diff; what lands next is the adversary that proves each one fails
without it — path traversal, symlink attacks, the directory swap fired
at the instant of the rename, clobber protection, corrupt manifests,
atomicity under an injected crash, and the telemetry allowlist. This
PR's own tests cover the happy path, reconcile semantics, manifest
round-tripping, resilience, and verify-before-write. Splitting
`skills_fs.py` itself along that line would have meant shipping a
deliberately weakened `_write_one`/`_prune` here and hardening it there,
which `agents.md` marks non-relaxable.

## Testing

`uv run pytest` → 1216 passed. `ruff check`, `ruff format --check`, and
`mypy packages/*/src` all clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds **Agent Skills materialization**: `write_skills` reconciles
verified skills to `<root>/<key>/SKILL.md`, tracks SDK-owned paths in
`<root>/.launchdarkly-skills.json`, and returns a **`ReconcileReport`**
(`written` / `updated` / `skipped_current` / `removed` / `error`, plus
`.ok` / `.errors`).
> 
> Implementation lives in new **`skills_fs.py`** (split from retrieval
in `skills.py`), using descriptor-pinned **`safe_fs`** writes,
manifest-authorized overwrites/deletes, fail-closed behavior on corrupt
manifests, **no prune** when retrieval is incomplete or verification
fails (so outages/tampering cannot masquerade as revocation), re-verify
before write, and optional **`"*"`** / **`prune`** / **`timeout`** /
**`on_unavailable`**.
> 
> **Public API** expands via `__init__.py`: `write_skills`,
`ReconcileAction` / `ReconcileReport` / `ReconcileActionKind`,
`OnUnavailable`, and `SKILL_FILENAME` / `MANIFEST_FILENAME` /
`MANIFEST_VERSION`. README and **`agents.md`** document the flow,
blocking async I/O note, and invariants; **`skill_refs`** docs note that
dropped invalid entries can otherwise cause unintended prune.
> 
> Tests add **`test_skills_fs.py`** (happy path, manifest, reconcile,
root errors, resilience) plus report/export coverage in
**`test_skills.py`**; adversarial FS tests are deferred to a follow-up
PR per description.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4c6d965. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
XieX added a commit that referenced this pull request Sep 15, 2026
**PR 3 of 5** splitting draft #45 for review. Stacked on #51.

`main` ← #50#51 ← **`split/skills-safe-fs`** ←
`split/skills-materialization` ← `split/skills-fs-hardening`

> [!NOTE]
> **Aug 28:** rebased in place for the stack-wide bytes pivot
(`Skill.content: bytes`, `frontmatter()` deleted). No change to this
slice's own content beyond the rebase.

## What's here

`safe_fs.py`: the "write a file under a directory something else may be
racing you for" problem, solved once. Nothing here knows what a skill is
— the materialization layer (PR 4) is its only caller.

**Why descriptors and not paths.** A path check is only as good as the
last path resolution after it. Every `lstat` and containment check
validates an *inode*, but a following `os.replace(tmp, dir / name)`
re-resolves `dir` from its *name* — so anything holding write permission
there can move the validated directory aside, leave a symlink in its
place, and redirect the write or the unlink somewhere else. Narrowing
that window is not a fix; the race is winnable at any width. So the
checks hand off to a descriptor and nothing re-resolves a path
afterwards.

- `open_directory_nofollow` opens with `O_RDONLY | O_DIRECTORY |
O_NOFOLLOW` and confirms `S_ISDIR` on the `fstat`, since not every
platform defines `O_DIRECTORY`. `open_or_create_directory` adds
`os.mkdir` plus an `lstat` on the `FileExistsError` path, because
`Path.mkdir(exist_ok=True)` accepts a symlink-to-directory as "already
there" and would reopen the hole the caller's check just closed.
`pinned_directory` holds either for a block, so a caller states the
platform split once as `if dir_fd is not None` and cannot forget the
`os.close`.
- `atomic_write` creates its temp file with `O_CREAT | O_EXCL |
O_NOFOLLOW` **at** that descriptor (`_mkstemp_at`, since `tempfile` has
no `dir_fd` form), `fchmod`s the descriptor rather than `chmod`ing a
path, writes, fsyncs, renames, and fsyncs the directory so the rename
survives a crash. Mode is set explicitly at `0644` — never inherited
from the umask, never executable. `os.replace` is the single rename call
site and `os.rename` must not be substituted for it.
- `unlink_file` probes and unlinks descriptor-relative. `unlink` never
follows a *trailing* symlink but it does resolve the directory above it,
so the same swap turns a removal into a delete of an arbitrary file. A
symlink found where this SDK expects its own file raises
`SymlinkRefused` rather than being tidied away — the state on disk is
not what the caller believes, and that is the caller's to report.

**The capability probe is not the obvious one.** `SUPPORTS_DIR_FD`
deliberately names `os.rename` and `os.stat` rather than the
`os.replace` and `os.lstat` this module actually calls:
`os.supports_dir_fd` is populated per underlying syscall, and CPython
registers `renameat` under `rename` only and `fstatat` under `stat`
only. Probing the names actually called reports "unsupported" on every
POSIX platform and silently turns the defense off. Where the family is
absent (Windows) every operation falls back to the identical full-path
sequence, the per-component `lstat` floor.

## Notes for reviewers

The 22 tests here exercise the module **directly**, on its own terms —
they are new work, not present in #45, added so this slice does not ask
you to read 311 lines of security-critical primitives with nothing
asserting them. In #45 every one of these guarantees was reached only
through `write_skills`.

The TOCTOU races these primitives exist to close are proved in PR 5,
which is what actually holds a descriptor across a sequence of
operations and can fire a directory swap at the instant of the rename.

## Testing

`uv run pytest` → 1167 passed. `ruff check`, `ruff format --check`, and
`mypy packages/*/src` all clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Introduces **`safe_fs.py`**, a skills-agnostic layer for
**descriptor-pinned** directory I/O so writes and unlinks don’t
re-resolve paths after validation (closing TOCTOU symlink-swap races).
On POSIX it uses `*at()`-style operations via `pinned_directory`,
`atomic_write` / `atomic_write_in`, and `unlink_file` (with
**`SymlinkRefused`** when a symlink stands in for an expected file); on
Windows it falls back to the documented per-component `lstat` path when
**`SUPPORTS_DIR_FD`** is false, using a non-obvious capability probe so
CPython’s `os.supports_dir_fd` doesn’t silently disable the defense.
> 
> Adds **`test_safe_fs.py`** (~22 tests) for pinning, atomic writes
(mode `0644`, temp cleanup, single `os.replace` site), unlink behavior,
and the dir-fd probe. **`agents.md`** documents the module in the file
map and a new “Descriptor-pinned filesystem access” section. Integration
with skill materialization is left to a follow-up slice; nothing else in
the client imports this module yet.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9571dca. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
XieX added a commit that referenced this pull request Sep 15, 2026
…/5) (#51)

**PR 2 of 5** splitting draft #45 for review. Stacked on #50 — review
that first; this diff is against it.

`main` ← `split/skills-references` (#50) ← **`split/skills-retrieval`**
← `split/skills-safe-fs` ← `split/skills-materialization` ←
`split/skills-fs-hardening`

> [!IMPORTANT]
> **Aug 28 repivot: skills are now opaque byte buffers by
construction.** `Skill.content` is `bytes` (the verified verbatim bytes,
exactly what was hashed), the `frontmatter()` convenience accessor and
`frontmatter.py` are deleted, and the SDK no longer parses or interprets
skill content anywhere. Consumers who want frontmatter parse it
themselves. The stack was rebased in place to make each change in the
slice that introduced the code; the TypeScript SDK is getting the mirror
change (`content: Uint8Array`) separately.

## What's here

The layer that turns a reference into content: an injectable store
interface, integrity verification of everything it serves, and a
body-free telemetry interface for the failures.

| Export | Description |
|---|---|
| `get_skill(key, *, version=None)` | One verified skill, or `None`. |
| `get_skills(refs)` | Batch form; accepts `SkillReference` values and
bare key strings. |
| `all_skills()` | Every verified skill the store holds, one per key at
its newest version. |
| `SkillStore` | The structural interface content arrives through:
`get_object(kind, key, version=None)`, `all_objects(kind)`, optional
`add_listener(kind, fn)`. |
| `InMemorySkillStore(objects=None)` | Dict-backed store with
`put(raw)`. Holds several versions of a key. |

Configure with `init_client(options={"skillStore": store})`. The
LaunchDarkly delivery transport drops in behind the same seam with no
public API change.

## Verification

Store data is untrusted; the transport is not part of the trust
boundary. Key and version are revalidated, size is bounded, and the
sha256 of the verbatim bytes must match the delivered `contentHash`. The
wire object delivers content as a JSON string; the UTF-8 encode happens
exactly once, inside verification, and the `Skill` handed to user code
carries the verified verbatim bytes (`Skill.content: bytes`) — the exact
byte sequence that was hashed. Anything that does not verify is withheld
and treated as missing, so no unverified content is ever returned.
`verified_bytes` also accepts already-`bytes` content and hashes it
directly, for the pre-write re-verification pass PR 4 adds — the "not
encodable as UTF-8" branch applies only to wire-shaped `str` input.
Content carrying an unpaired surrogate has no UTF-8 encoding at all and
is withheld too — `str.encode` is called strictly, never with an error
handler that would fabricate bytes a hash comparison could then accept.

Integrity failures go through a private telemetry seam carrying hashes
and byte counts only. The two properties copied off the wire,
`skill_key` and `expected_hash`, are shape-checked and replaced when
malformed, so a hostile store cannot use either to publish the body
through a signal that is otherwise body-free. The default emitter is a
no-op; the three signal names are an allowlist maintained in one section
of one module.

## Review findings addressed

**Finding 1 (blocking) — version pinning could not be expressed by the
store seam.** Version is now part of the lookup identity rather than a
filter applied to the answer. A delivery payload carries the newest
version of every skill *plus* every version any variation currently
pins, so two versions of one key coexist routinely; a seam keyed by key
alone answered a pinned reference with the newest object and then
rejected it, turning the primary use case into a missing skill.

- `SkillStore.get_object(kind, key, version=None)`; `version=None` means
"the newest you hold".
- The post-fetch equality check stays, now as a **defense** rather than
the selection mechanism: the store is untrusted, so an answer that is
not the version asked for is withheld.
`TestVersionPinning::test_a_store_answering_with_the_wrong_version_is_withheld`
covers it.
- `InMemorySkillStore` keys by `(key, version)` and holds both. An
object whose version is unusable is still served, under its key alone —
withholding it is verification's job, so a malformed object stays
distinguishable from an absent one and still records a signal.
- `all_objects` returns one entry per `(key, version)` under keys
documented **opaque**; identity is read off each object's own fields.
`newest_by_key` is the single place that collapses a whole-store read to
one object per key, because a list holding two versions of one key is
not a set of skills.
- `TestVersionPinning` holds two versions of one key and asserts a
pinned-old lookup, a latest lookup, both against one store, a mixed
batch, and `all_skills()` returning one entry per key. Reverting just
the seam change fails three of them, so they are not vacuous.

**Finding 2 — `contentHash` mandatory here, optional on the wire.** It
stays mandatory; that default is right. What changes is the signalling:
a run that withheld anything logs one WARN naming the counts, and a run
where *nothing* verified says so explicitly and names `contentHash` —
that case previously returned an empty result indistinguishable from
"this project has no skills". `TestWithholdingSummary` covers total,
partial, batch-scoped, and silent-on-success. The other half of finding
2 — whether the wire object needs a hashing-algorithm discriminator
rather than a bare hex string — is a delivery-contract decision, not an
SDK one; it is tracked on #45 rather than resolved here.

**Finding 3 — the object kind may not match the real delivery kind.**
`SKILL_OBJECT_KIND` keeps its value but is **no longer exported from the
package root**. It is the string this SDK hands a store, and an adapter
maps whatever the transport underneath calls a skill onto it; publishing
it would advertise an SDK-side seam value as the wire contract — a claim
this side cannot make, and hard to walk back once a caller depends on
it. An adapter that needs to agree with it reaches it through
`skills_core`. `test_object_kind_is_not_public_api` asserts it is absent
from both `__all__` and the package namespace. **This needs the same
change in the TypeScript SDK**, as does finding 1's seam shape.

`MAX_SKILL_CONTENT_BYTES` stays internal for the adjacent reason: it is
a local enforcement bound on content the platform produces, so exporting
it would semver-lock a number this side does not own.

## Notes for reviewers

- **Behaviour change.** `shutdown()` clears the configured skill store
along with the client. `init_client` applies `skillStore` on every
successful call, even the idempotent ones, which is what lets a lazily
auto-initialized client be given a store afterwards.
- `record_materialized` and `record_revoked` land here with no caller —
the materialization layer is PR 4. They live beside
`record_integrity_failure` so the three-signal allowlist is one section
of one file rather than three sites to audit.

## Testing

`uv run pytest` → 1145 passed. `ruff check`, `ruff format --check`, and
`mypy packages/*/src` all clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> This PR adds the **content retrieval** layer for Agent Skills:
verified skill bodies are read through an injectable **`SkillStore`**,
wired via **`init_client(options={"skillStore": ...})`**.
> 
> **Public API:** **`get_skill`**, **`get_skills`**, **`all_skills`**,
**`SkillStore`**, and **`InMemorySkillStore`** join existing
**`skill_refs`**. Accessors return **`None`** or omit entries when
content is missing or fails checks; they **`RuntimeError`** only if no
store is configured. Lookups are **version-aware** (`get_object(kind,
key, version=None)`) so pinned references resolve to the intended
version, not always the newest.
> 
> **Internals:** New **`skills_core.py`** centralizes store/emitter
state, **`verify_raw_skill`** / **`verified_bytes`** (required
**`contentHash`**, 10 MiB cap, strict UTF-8), shared
**`resolve_from_store`**, and a three-signal telemetry allowlist
(default no-op emitter). Withheld skills emit stable
**`ld.skills.integrity_failure`** ERROR logs (SIEM contract) plus
optional product signals; run-level **WARN** summaries when batches
withhold content.
> 
> **Lifecycle:** **`init_client`** applies **`skillStore` on every
successful call** (even when the LD client is already initialized) but
never clears an existing store unless **`shutdown()`** (which also
clears skill module state). Failed init does not install a store.
> 
> **Docs/tests:** README and **`agents.md`** document integrity
detection and security invariants; **`test_skills.py`** expands heavily
(version pinning, withholding, log record shape, telemetry allowlist).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
daf04a6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@XieX
XieX deleted the xie/agent-skills-feature-ac9ac7 branch September 15, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants