feat(client): Agent Skills — materialize onto disk under a manifest (4/5) - #53
Open
XieX wants to merge 3 commits into
Open
feat(client): Agent Skills — materialize onto disk under a manifest (4/5)#53XieX wants to merge 3 commits into
XieX wants to merge 3 commits into
Conversation
This was referenced Aug 25, 2026
donei003
approved these changes
Aug 27, 2026
knfreemLD
approved these changes
Aug 28, 2026
XieX
force-pushed
the
split/skills-safe-fs
branch
from
August 28, 2026 18:03
8f935da to
f4091b4
Compare
XieX
force-pushed
the
split/skills-materialization
branch
from
August 28, 2026 18:03
b0d9e87 to
3709086
Compare
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>
XieX
force-pushed
the
split/skills-safe-fs
branch
from
August 28, 2026 20:29
f4091b4 to
8ad49f2
Compare
XieX
force-pushed
the
split/skills-materialization
branch
from
August 28, 2026 20:29
3709086 to
568729d
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 568729d. Configure here.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

PR 4 of 5 splitting draft #45 for review. Stacked on #52.
main← #50 ← #51 ← #52 ←split/skills-materialization←split/skills-fs-hardeningImportant
Aug 28 repivot: skills are now opaque byte buffers by construction.
Skill.contentisbytes(the verified verbatim bytes, exactly what was hashed), thefrontmatter()convenience accessor andfrontmatter.pyare 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_skillswrites<root>/<key>/SKILL.mdand 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.skillsacceptsSkillvalues, references, bare keys, or the literal"*"for everything the store holds. Every outcome is visible in the returnedReconcileReport: oneReconcileActionper skill carryingwritten,updated,skipped_current,removedorerror, plus.okand.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.NAME_MAXis 255 bytes, so an over-long key is refused too.keymatches.erroraction names the manifest, and the manifest is not rewritten.Skillcan also be constructed directly by a caller. With the bytes pivot, this pass handsSkill.content: bytesto the sameverified_bytesthe 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.
timeoutbounds 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.mdis 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_skillsblocks. It isasyncfor parity with the other accessors and with the TypeScript SDK, but it awaits nothing: every read, write,fsyncand rename runs inline. Wrap it inasyncio.to_threadif that matters on your loop. For the same reasontimeoutis 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 feat(client): Agent Skills — umbrella for the #50–#54 split #45 both live here and are tracked there, not resolved in this PR.)write_skillscreates 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 raiseValueError— caller errors, distinct from the per-skillerroractions in the report.skills_fs.pyitself along that line would have meant shipping a deliberately weakened_write_one/_prunehere and hardening it there, whichagents.mdmarks non-relaxable.Testing
uv run pytest→ 1216 passed.ruff check,ruff format --check, andmypy packages/*/srcall clean.🤖 Generated with Claude Code
Note
Overview
Adds Agent Skills materialization:
write_skillsreconciles verified skills to<root>/<key>/SKILL.md, tracks SDK-owned paths in<root>/.launchdarkly-skills.json, and returns aReconcileReport(written/updated/skipped_current/removed/error, plus.ok/.errors).Implementation lives in new
skills_fs.py(split from retrieval inskills.py), using descriptor-pinnedsafe_fswrites, 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, andSKILL_FILENAME/MANIFEST_FILENAME/MANIFEST_VERSION. README andagents.mddocument the flow, blocking async I/O note, and invariants;skill_refsdocs 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 intest_skills.py; adversarial FS tests are deferred to a follow-up PR per description.Reviewed by Cursor Bugbot for commit 4c6d965. Bugbot is set up for automated code reviews on this repo. Configure here.