Conversation
…pin them Each of the three was pinned the wrong way by an existing test, which is why all three survived review. The test rewrites are the substance of this change as much as the source fixes are; each one was verified by mutation. **1. Unpublish `SKILL_OBJECT_KIND` and `MAX_SKILL_CONTENT_BYTES`.** Both were re-exported from the package root. Neither belongs there, and their absence is itself the contract: - `SKILL_OBJECT_KIND` is an SDK-side seam string, not the wire format. It is what the accessors hand `SkillStore.getObject`, and an adapter is free to map it onto whatever its transport actually uses. Exporting it advertises a claim this side cannot make and could not walk back once a caller depended on it. - `MAX_SKILL_CONTENT_BYTES` is a local enforcement bound on content the platform produces, set well above the platform's own limit precisely so that limit can move without this constant following. Publishing it semver-locks a number this side does not own, and a caller pre-flighting "will my skill fit?" against it reads the backstop rather than the real bound. The test that should have caught this asserted the opposite — titled "the five fixed values", with a comment claiming callers need the cap to pre-check content, which is the exact reading the contract rejects. It now asserts the three public constants' values and the two names' *absence*, mirroring `test_skills.py`. Tests that need the cap keep reading it from `skills-core.js`, which is where it lives. **2. A store answering under a different key is `integrity_failure`.** It returned `wrong_version`, which names a version mismatch specifically — there is deliberately no `wrong_key` to parallel it. Content was delivered and its identity did not verify, which is the one outcome a caller is expected to fail closed on; filed under `wrong_version`, skill substitution was invisible to a fail-closed caller. The JSDoc already described the intent correctly; only the token was wrong. The no-signal/no-log half is deliberate and unchanged: the check runs *after* verification has passed, so the eight-token `reason_code` vocabulary does not cover it and neither telemetry surface fires. The test now asserts that asymmetry in both directions — the outcome is `integrity_failure`, the recording emitter saw nothing, and no `ld.skills.integrity_failure` record was logged — and branches on the token rather than matching `detail` text, which is for a human. **3. `AgentControl Skill Revoked Received` omits `version` rather than emitting null.** `recordRevoked` built its properties unconditionally, so a manifest entry with a malformed version published `version: null`. It now adds the property only for a valid version, and the caller passes the raw manifest value through instead of pre-collapsing it to null. `skill_key` is now redacted through `isValidSkillKey` there too, as Python's `record_revoked` does. The key comes from the manifest — a file on disk anything with write access to the root can author — so it is exactly as attacker-controlled as a wire object. Reachability note: `pruneEntries` already refuses an entry whose key fails validation before `pruneOne` runs, so this is the second line of defense behind that guard; the end-to-end test asserts the body stays out of telemetry either way, and a direct call pins the redaction itself, which end-to-end cannot reach. Also in scope, same theme: - `getSkill` asserts `content` is a `Uint8Array`. `toEqual` alone passes for any structurally-equal value, including a plain array of the same numbers. - The `SkillOutcomeReason` exhaustiveness check named the union through `types.js`. It is the *root* export that is fixed — a union reachable only from the implementation module is not reachable by a supported import — so it now goes through the package index. The file's two root-export suites are merged while we are in here. - README and `agents.md` documented both constants as public API, and the resolution-reason table had no row for a key mismatch. The README's `integrity_failure` row also promised an `ld.skills.integrity_failure` record for every case reaching that token, which the key-mismatch path does not write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
Author
|
Collapsed into #65, which now carries this chain's six commits as a single review against Nothing was rebased or dropped — the commits are byte-identical and this PR's commit is among them. Closing here because the chain was cleanly additive (one reworked line across all five PRs), so the five separate reviews only cost context on This description stays the authoritative rationale for its part of the change — #65 links back here per commit rather than restating it. |
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.
Three big fixes for the Agent Skills implementation, that came out of agent code review.
SKILL_OBJECT_KINDandMAX_SKILL_CONTENT_BYTES).wrong_versionerror to anintegrity_failureerror.recordRevokedomitsversionif it's malformed rather than includingversion: null.Plus two test fixes and some docs updates.
1. Unpublish
SKILL_OBJECT_KINDandMAX_SKILL_CONTENT_BYTESBoth were re-exported from the package root. Neither belongs there, and the absence is itself the contract:
SKILL_OBJECT_KINDis an SDK-side seam string, not the wire format. It is what the accessors handSkillStore.getObject, and an adapter is free to map it onto whatever its transport actually uses. Exporting it advertises a claim this side cannot make and could not walk back once a caller depended on it.MAX_SKILL_CONTENT_BYTESis a local enforcement bound on content the platform produces, set well above the platform's own limit precisely so that limit can move without this constant following. Publishing it semver-locks a number this side does not own, and a caller pre-flighting "will my skill fit?" against it reads the backstop rather than the real bound.The test that should have caught this was titled "the five fixed values" and carried a comment claiming callers need the cap to pre-check content — the exact reading the contract rejects. It now asserts the three public constants' values and the two names' absence, mirroring
test_skills.py. Tests that need the cap keep reading it fromskills-core.js.2. Key mismatch is
integrity_failure, notwrong_versionresolveFromStorereturnedwrong_versionwhenskill.key !== key.wrong_versionnames a version mismatch specifically — there is deliberately nowrong_keyto parallel it. Content was delivered and its identity did not verify, which is the one outcome a caller is expected to fail closed on; filed underwrong_version, skill substitution was invisible to a fail-closed caller. The JSDoc already described the intent correctly; only the token was wrong.The no-signal/no-log half is deliberate and unchanged: the check runs after verification has passed, so the eight-token
reason_codevocabulary does not cover it and neither telemetry surface fires. The test now asserts that asymmetry in both directions — the outcome isintegrity_failure, the recording emitter saw nothing, and nold.skills.integrity_failurerecord was logged — and branches on the token rather than matchingdetailtext, which is for a human.3.
AgentControl Skill Revoked Receivedomitsversionrather than emitting nullrecordRevokedbuilt its properties unconditionally, so a manifest entry with a malformed version publishedversion: null. It now adds the property only for a valid version, and the caller passes the raw manifest value through instead of pre-collapsing it to null. The new test usesnot.toHaveProperty('version')— readingprops.versionand comparing to undefined would pass either way.skill_keyis now redacted throughisValidSkillKeythere too, as Python'srecord_revokeddoes. The key comes from the manifest — a file on disk anything with write access to the root can author — so it is exactly as attacker-controlled as a wire object.Reachability, stated plainly:
pruneEntriesalready refuses an entry whose key fails validation beforepruneOneruns, so no Revoked signal is emitted for a hostile key today and this redaction is the second line of defense behind that guard. The end-to-end test asserts the body stays out of telemetry either way; a directrecordRevokedcall pins the redaction itself, which end-to-end cannot reach. Both are commented to say so, so neither reads as stronger than it is.Also in scope, same theme
getSkillassertscontentis aUint8Array.toEqualalone passes for any structurally-equal value, including a plain array of the same numbers. (TheSkill-vs-SkillReferenceruntime discriminator inskills-fs.tswas checked and is already correct —content instanceof Uint8Array.)SkillOutcomeReasonexhaustiveness check named the union throughtypes.js. It is the root export that is fixed — a union reachable only from the implementation module is not reachable by a supported import — so it now goes through the package index. The file's two root-export suites are merged while we are in here.agents.mddocumented both constants as public API. Two further doc gaps the token change opened: theagents.mdresolution-reason table had no row for a key mismatch, and the README'sintegrity_failurerow promised anld.skills.integrity_failurerecord for every case reaching that token, which the key-mismatch path does not write.🤖 Generated with Claude Code, updated by @XieX
Note
Overview
This PR tightens Agent Skills public contracts and fail-closed behavior in
@launchdarkly/ai-server, with tests rewritten to match (several previously asserted the wrong outcome).Breaking API:
SKILL_OBJECT_KINDandMAX_SKILL_CONTENT_BYTESare removed from the package barrel; onlySKILL_FILENAME,MANIFEST_FILENAME, andMANIFEST_VERSIONstay exported. Docs explain both constants remain internal toskills-coreso callers do not semver-lock the size backstop or treat the store kind string as the wire format.getSkillResultsemantics: When the store returns verified content under a different key than requested, the outcome is nowintegrity_failure(notwrong_version), so fail-closed branching treats skill substitution like tampering. That path still does not emitld.skills.integrity_failureor integrity telemetry — only hash/shape failures insideverifyRawSkilldo. README andagents.mddocument the split.Revoked signal:
recordRevokedshape-checks manifest-sourcedskill_key(invalid keys redacted) and omitsversionwhen it is not a valid integer instead of sendingversion: null. Prune passes raw manifest values through to the recorder.Tests add export-absence assertions, key-mismatch silence checks, revoked telemetry cases, and
getSkillUint8Arraytyping; package-export suites are consolidated.Reviewed by Cursor Bugbot for commit 937131d. Bugbot is set up for automated code reviews on this repo. Configure here.