feat(client): Agent Skills store seam, verification, and accessors - #31
Merged
Merged
Conversation
This was referenced Aug 25, 2026
XieX
force-pushed
the
xie/skills-03-core-accessors
branch
2 times, most recently
from
August 28, 2026 20:30
310f204 to
b723c18
Compare
XieX
force-pushed
the
xie/skills-03-core-accessors
branch
from
August 31, 2026 18:10
b723c18 to
49d4e9b
Compare
XieX
force-pushed
the
xie/skills-03-core-accessors
branch
from
September 14, 2026 20:13
49d4e9b to
ccad73f
Compare
XieX
marked this pull request as ready for review
September 14, 2026 20:35
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ccad73f. Configure here.
Contributor
|
Would be great to add a test regarding this: |
The whole non-filesystem runtime, and the first commit in the stack where the feature does something end to end. `skills-core.ts` holds what the two layers above it share: the `SkillStore` and telemetry seams, the module state behind them, integrity verification, and store resolution. It imports neither of those layers. Keeping the store and the emitter here is what makes it impossible for the accessor layer and the filesystem layer to disagree about whether one is configured, so the dependency edge that would close that cycle must not be added. `skills.ts` is the public half: `skillRefs`, `getSkill`/`getSkills`/`allSkills`, `InMemorySkillStore`, and the documented injection points. `lifecycle.ts` accepts `skillStore` on both `initClient` overloads — the BYOC overload gains an options argument, which is how an edge-runtime caller configures one. It is applied before the idempotency check and on every call, so a client that initialized lazily, or without a store, can be given one afterwards; a nullish value never clears a configured store. `shutdown()` clears the skills state unconditionally and ahead of its own early return, because that state can exist without a client. Content delivery is not wired up. Everything runs against the `SkillStore` seam and the shipped default is absent, so the accessors throw an actionable error until one is configured. `InMemorySkillStore` covers local development, tests, and bring-your-own-content. The real transport drops in behind the same interface without touching the public API. Nothing a store serves is trusted. The transport is outside the trust boundary, so key, version, size, and content hash are revalidated at the accessor boundary on every pass — a `Skill` only exists once verification has passed, and `writeSkills` will re-verify anyway. Telemetry goes through a private no-op emitter and never `client.track()`. These are LaunchDarkly product-analytics signals, not customer analytics: `track()` would require an LD context, spend the customer's event volume, and land in their data export. Exactly three signal names exist, they are an allowlist rather than a floor, and a sweep over the recorded names enforces that. No skill content and no filesystem paths reach a signal — hashes and byte counts only. The wire object delivers `content` as a JSON string; `verifiedBytes` encodes it to UTF-8 bytes exactly once (TextEncoder), hashes the bytes, and the verified bytes are what the `Skill` carries — "skills are opaque byte buffers" is true by construction from here on. The pre-write pass hands a `Skill`'s bytes straight back in and they are hashed as-is. One defense here is invisible from the outside and could be deleted with every other test still green: the UTF-8 round-trip guard in `verifiedBytes`. TextEncoder silently substitutes U+FFFD for an unpaired surrogate where Python raises, so without the guard a store supplying the sha256 of the *substituted* bytes has fabricated content pass verification. Its test pins `contentHash` to exactly that hash, so the hash comparison is provably not what rejects it. Client package: 342 -> 395 tests. typecheck and biome clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A default `TextDecoder` consumes a leading U+FEFF (`ignoreBOM` defaults
to false, which means "handle the BOM" — i.e. strip it). The UTF-8
round-trip guard in `verifiedBytes` compared that decoded string against
the original, so authentic content starting with a BOM round-tripped to a
shorter string and was withheld as `not_utf8` even though its bytes
hashed correctly — surfacing to callers as `integrity_failure`.
Decoding with `{ ignoreBOM: true }` passes U+FEFF through, so the
comparison only fires on content that genuinely has no UTF-8 encoding.
Verified that the lone-surrogate case this guard exists for is still
caught, since TextEncoder substitutes U+FFFD there regardless of the BOM
setting. Regression test lands with the other verification tests in the
next PR in the stack.
Reported by Cursor Bugbot on #31.
Also, per review feedback, the integrity vocabularies and the byte-identical
JSON requirement are now stated as cross-SDK properties rather than as
facts about the Python SDK. The constraints are unchanged: the eight
reason_code tokens and alphabetical key insertion order are still
load-bearing, and the comments still say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
force-pushed
the
xie/skills-03-core-accessors
branch
from
September 16, 2026 18:26
ccad73f to
8d4bad2
Compare
andrewklatzke
approved these changes
Sep 16, 2026
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.

Third of seven. The whole non-filesystem runtime, and the first PR in the stack where the feature does something end to end.
Layering
skills-core.tsholds what the two layers above it share: theSkillStoreand telemetry seams, the module state behind them, integrity verification, and store resolution. It imports neither of those layers.Keeping the store and the emitter here is what makes it impossible for the accessor layer and the filesystem layer to disagree about whether one is configured. The dependency edge that would close that cycle must not be added.
skills.tsis the public half:skillRefs,getSkill/getSkills/allSkills,InMemorySkillStore, and the documented injection points.lifecycle.tsacceptsskillStoreon bothinitClientoverloads. The BYOC overload gains an options argument, which is how an edge-runtime caller configures one. It's applied before the idempotency check and on every call, so a client that initialized lazily — or without a store — can be given one afterwards. A nullish value never clears a configured store.shutdown()clears the skills state unconditionally and ahead of its own early return, because that state can exist without a client.The wire string is encoded exactly once, here
The wire object delivers
contentas a JSON string.verifiedBytesencodes it to UTF-8 bytes exactly once (TextEncoder), enforces the size cap on the encoded bytes, hashes the bytes (sha256, lowercase hex — byte-identical with the Python SDK, never a string API), and the verified bytes are what theSkillcarries. "Skills are opaque byte buffers" is true by construction from here on: nothing downstream re-encodes, decodes, or interprets them. The sameverifiedBytesaccepts aUint8Arrayfor the pre-write pass in #33, where aSkill's bytes are hashed as-is.Content delivery is not wired up
Everything runs against the
SkillStoreseam and the shipped default is absent, so the accessors throw an actionable error until one is configured.InMemorySkillStorecovers local development, tests, and bring-your-own-content. The real transport drops in behind the same interface without touching the public API.Trust boundary
Nothing a store serves is trusted. The transport is not part of the trust boundary, so key, version, size, and content hash are revalidated at the accessor boundary on every pass. A
Skillonly exists once verification has passed — andwriteSkillswill re-verify anyway, because aSkillcan also be built by a caller.Telemetry
Signals go through a private no-op emitter and never
client.track(). These are LaunchDarkly product-analytics signals, not customer analytics:track()would require an LD context, spend the customer's event volume, and land in their data export.Exactly three signal names exist and they are an allowlist, not a floor. No skill content and no filesystem paths reach a signal — hashes and byte counts only.
One defense worth reviewing closely
The UTF-8 round-trip guard in
verifiedBytesis invisible from the outside and could be deleted with every other test still green.TextEncodersilently substitutes U+FFFD for an unpaired surrogate where Python raises — so without the guard, a store supplying the sha256 of the substituted bytes has fabricated content pass verification. The guard runs only on the wire-string path; bytes handed in directly are already just bytes.Its test (in the next PR) pins
contentHashto exactly that hash, so the hash comparison is provably not what rejects it.getSkillResult— a distinguishable outcome for tampering (security review LA-2)getSkillresolves tonullfor four distinct outcomes: no such skill, the store threw, the requested version is not the one held, and content failed hash verification. A customer therefore cannot fail closed on suspected tampering while tolerating a merely-absent skill, so no automated customer-side response is possible. The information already existed internally —Resolutiondistinguished all four — but only as prose in itserrorstring, whichgetSkilldiscarded.getSkillResult(key, { version? })resolves to{ skill, reason, detail }.skillis non-null exactly whenreasonis'ok';detailis the existing human-readable reason string, which carries no skill content and no filesystem path and must stay that way.getSkillis unchanged. Its documented contract — "resolves tonull, never rejects; throws only when no store is configured" — is frozen, because every existing caller treats thatnullas "no skill".getSkillResultshares the identical lookup, the identical verification, and the identical single throw; the two differ only in what they report. #32 assertsgetSkillstill resolves tonullfor all four failures.Resolutiongains a typedreasonfield, set explicitly at every construction site rather than derived fromerror— pattern-matching a prose string to decide what a customer's fail-closed branch sees is precisely the fragility this finding is about. The field is required, so a sixth internal outcome has to choose a public token rather than inherit'absent'by omission. The five sites map 1:1:resolveFromStoreoutcomereasonunavailable)store_unavailablerawis not an objectabsentverifyRawSkillreturnednullintegrity_failureskill.version !== wantedVersionwrong_versionokunavailablestays as it is. It is load-bearing on the prune path — only a throwing store suppresses pruning, because deleting managed files after a failed lookup would turn an outage into data loss — andstore_unavailablestays distinct fromabsentfor the same reason.The store seam now carries the version
resolveFromStorepreviously accepted awantedVersionand then calledstore.getObject(kind, key)without it, relying on a post-hoc equality check. That worked while the reason was invisible; publishing a typedwrong_versionon top of it would report the wrong reason for a customer store that holds several versions of a key and would have answered the pin correctly if asked. The version is now threaded through, and the equality check is kept as a defense rather than as the selection mechanism, because the store is untrusted.InMemorySkillStoreaccepts the parameter and holds one object per key, so it answers with what it has and lets the accessor refuse a mismatch — deliberately not a filter, since returningnullfor an unsatisfiable pin would report a version mismatch as an absence. Giving it real multi-version semantics is a separate change that pulls inallObjectsandallSkills.Deliberately not built
ld.skills.integrity_failurerecord already fired inside verification before the resolution returned; recording anything here would double-log one failure. test(client): integrity verification and the accessor telemetry sweep #32 asserts exactly one record per failed retrieval.IntegrityReasonCodeinSkillOutcome.verifyRawSkillreturnsnulland does not surface which of the eight tokens fired. Plumbing it up would change that function's return type for a caller-facing detail the operator already gets from the log record.getSkillsandallSkillskeep omitting entries they could not return. Possible follow-up: a reporting form for the batch accessors, if a caller turns out to need per-entry reasons across a whole reference set rather than per key.Verification
Client package 355 → 395 tests.
typecheckandbiomeclean.🤖 Generated with Claude Code
Note
Overview
Adds the Agent Skills runtime for retrieving verified skill content through an injectable
SkillStore, without wiring LaunchDarkly delivery yet.New modules:
skills-core.tscentralizes global store/telemetry state (onglobalThis), SHA-256 integrity verification withld.skills.integrity_failurelogging, and sharedresolveFromStore.skills.tsexposesskillRefs,getSkill/getSkills/allSkills,getSkillResult(typedreasonfor tampering vs absent vs store outage), andInMemorySkillStorefor local/BYOC use.Lifecycle:
initClientacceptsskillStoreon both overloads (applied before client idempotency; omitting it does not clear an existing store).shutdown()always clears skills state.Trust model: Wire
contentis UTF-8–encoded once; failed verification is omitted from batch results and emits integrity telemetry (no skill bodies in signals). Store lookups pass version intogetObject, with a post-check defense for untrusted stores.Tests expand
skills.test.tswith OTel mocks, store lifecycle, accessors, and integrity signal coverage. Public exports added inindex.ts.Reviewed by Cursor Bugbot for commit 8d4bad2. Bugbot is set up for automated code reviews on this repo. Configure here.