fix: address Copilot review round 9 on #2248

**Verification reads were unbounded.** The 512-entry limit is checked by
`checkSkillConformance` — as a warning, since the SEP makes it an
interoperability bound rather than a MUST — but checking it constrains
nothing, so a hostile server advertising a million entries had the tool
perform a million sequential reads after the report already knew the manifest
was over. Capped at `SKILL_MAX_RESOURCE_ENTRIES`, with the finding still
reported so bounding the work does not silence the reason it was
unnecessary. `manifestListsSelf` is computed over the READ slice, so a
self-entry pushed past the cap still reaches the fallback: the frontmatter
comparison is mandatory and must not be lost to a limit that bounds other
files.

**A malformed skill URI still produced a directory root.** `skillUriIdentity`
falls back to the raw string, so `not a uri/SKILL.md` yielded the root `not a
uri` and enabled the Directory section — letting the UI build a request from
a URI the conformance checks had already rejected. The comment above it
claimed otherwise. `normalizeSkillUri` now, with the rule written down:
identity is for COMPARING two spellings, not for deciding a URI is
well-formed enough to build a request from.

The existing regression test passed against a weaker input that failed for
lacking the suffix and never reached the fallback; it is a table of three
shapes now.

**The test-server guide overstated its own coverage,** claiming four of the
five conformance scenarios map onto fixtures. Three do: there is no
size-mismatch fixture, because the fixture can override an advertised digest
and nothing else, so that path is covered by unit tests. Corrected, with what
adding one would take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-07 21:21:49 -04:00
commit 88c1b89575

View file

@ -33,6 +33,7 @@ import {
checkSkillFrontmatterMatch,
checkSkillNameCollisions,
skillDisplayName,
SKILL_MAX_RESOURCE_ENTRIES,
skillFileBytes,
skillUriIdentity,
verifySkillResource,
@ -68,7 +69,13 @@ export interface SkillVerifyReport {
*/
frontmatter: SkillIssue[];
/**
* One entry per manifest file, in manifest order.
* One entry per manifest file **read**, in manifest order.
*
* ⚠️ Capped at `SKILL_MAX_RESOURCE_ENTRIES`. A manifest longer than that is
* already reported by the `resource-limit-exceeded` warning, and reading all
* of it would let a server dictate an unbounded number of round trips — so
* this can be SHORTER than the declared manifest, and a consumer must not
* read its length as the manifest's.
*
* ⚠️ **Not necessarily empty for a `"dynamic"` skill.** Such a skill has no
* manifest rows, but a failed read of its own `SKILL.md` — the file the
@ -187,8 +194,16 @@ export async function verifySkills(
let entryBytes: Uint8Array | undefined;
const files: SkillFileReport[] = [];
const manifest =
const declared =
entry.resources === DYNAMIC_RESOURCES ? [] : entry.resources;
// ⚠️ **Bounded, because the manifest is server-controlled.** The 512-entry
// limit is CHECKED by `checkSkillConformance` — as a warning, since the SEP
// makes it an interoperability bound rather than a MUST — but checking it
// constrains nothing, so a hostile or broken server advertising a million
// entries had the tool perform a million sequential reads after the report
// already knew the manifest was over the limit (Copilot). The overage is
// reported by `resource-limit-exceeded`; reading it is what stops here.
const manifest = declared.slice(0, SKILL_MAX_RESOURCE_ENTRIES);
const entryIdentity = skillUriIdentity(entry.uri);
// Compared by NORMALIZED identity, like every other URI comparison here —
// `checkSkillConformance` already accepts a manifest self-entry written in
@ -244,7 +259,10 @@ export async function verifySkills(
//
// Gated on `manifestListsSelf` rather than on `entryBytes`, so a self-entry
// the loop already tried and FAILED to read is not read a second time — its
// failure is recorded there.
// failure is recorded there. Note this is computed over the READ slice, so
// a self-entry pushed past the cap by a bloated manifest still reaches the
// fallback: the frontmatter comparison is mandatory and must not be lost to
// a limit that exists to bound unrelated files.
if (!manifestListsSelf) {
// Recorded as a file result, not swallowed. Because a dynamic skill has
// no manifest rows, `files` would otherwise stay empty and its only static