**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>
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
/**
|
|
* Fetch-and-verify: run every SEP-2640 check that needs the *bytes* of a skill's
|
|
* files, over a whole set of entries (#2248).
|
|
*
|
|
* Separate from `skills.ts`, which is deliberately I/O-free —
|
|
* `checkSkillConformance` reads a manifest, `verifySkillResource` compares bytes
|
|
* it is handed, `checkSkillFrontmatterMatch` compares text it is handed, and
|
|
* none of them knows how to obtain a file. This module is the part that does,
|
|
* and keeping it in its own file is what lets the pure checks stay testable with
|
|
* no client at all.
|
|
*
|
|
* It lives in `core/` because **two** clients drive it: the CLI's `--verify`
|
|
* turns it into an NDJSON report with an exit code, and the TUI's Skills pane
|
|
* runs it for one selected skill. Only the web screen does something different
|
|
* — it fetches lazily, per click, because a browser user is reading one file at
|
|
* a time rather than asking a yes/no question about a catalog. That difference
|
|
* is about *when* to fetch, not *how* to check, so it does not belong here.
|
|
*
|
|
* ⚠️ Reads are **sequential, deliberately.** A conforming manifest may declare
|
|
* 512 entries, and a parallel walk over one would open 512 `resources/read`
|
|
* calls against a server whose whole purpose here is to be tested — the hazard
|
|
* the web screen bounds with a concurrency limit. Sequential also makes the
|
|
* report deterministic: entries come back in manifest order on every run, so a
|
|
* CI diff of two reports shows what changed rather than what raced.
|
|
*/
|
|
|
|
import { AuthRecoveryRequiredError } from "../auth/challenge.js";
|
|
import type { InspectorClientProtocol } from "./inspectorClientProtocol.js";
|
|
import type { RequestMetadata } from "./types.js";
|
|
import {
|
|
bytesToText,
|
|
checkSkillConformance,
|
|
checkSkillFrontmatterMatch,
|
|
checkSkillNameCollisions,
|
|
skillDisplayName,
|
|
SKILL_MAX_RESOURCE_ENTRIES,
|
|
skillFileBytes,
|
|
skillUriIdentity,
|
|
verifySkillResource,
|
|
type SkillIssue,
|
|
type SkillVerification,
|
|
} from "./skills.js";
|
|
import { DYNAMIC_RESOURCES, type SkillEntry } from "./skillsSchemas.js";
|
|
|
|
/** One manifest entry's outcome. `read-error` means the fetch itself failed. */
|
|
export type SkillFileStatus = SkillVerification["status"] | "read-error";
|
|
|
|
export interface SkillFileReport {
|
|
uri: string;
|
|
status: SkillFileStatus;
|
|
expectedDigest?: string;
|
|
actualDigest?: string;
|
|
expectedSize?: number;
|
|
actualSize?: number;
|
|
reason?: string;
|
|
}
|
|
|
|
/** One skill's full verdict, and the unit of the NDJSON stream. */
|
|
export interface SkillVerifyReport {
|
|
uri: string;
|
|
name: string;
|
|
/** Structural findings against the entry as listed. */
|
|
conformance: SkillIssue[];
|
|
/**
|
|
* Findings from comparing the served `SKILL.md`'s own frontmatter against the
|
|
* listed one. Empty when the file could not be read — the read failure is
|
|
* reported once, as a file result, rather than a second time as a phantom
|
|
* frontmatter discrepancy.
|
|
*/
|
|
frontmatter: SkillIssue[];
|
|
/**
|
|
* 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
|
|
* mandatory frontmatter comparison needs — is recorded here as a synthetic
|
|
* `read-error` row against the entry's URI, so the failure is visible and
|
|
* fails the report rather than passing silently. A consumer must not assume
|
|
* `files` mirrors the manifest one-for-one (Copilot).
|
|
*/
|
|
files: SkillFileReport[];
|
|
/**
|
|
* False when anything the SEP makes a MUST was broken: an error-severity
|
|
* finding, a digest or size mismatch, or a file that could not be read.
|
|
*
|
|
* A `warning` does **not** clear it — a `"dynamic"` manifest is legal, and a
|
|
* report that failed CI for it would be telling server authors their
|
|
* conforming skill is broken.
|
|
*/
|
|
ok: boolean;
|
|
}
|
|
|
|
/**
|
|
* The reason string for a failed read.
|
|
*
|
|
* One helper rather than the same ternary at each of the four call sites: a
|
|
* rejection is not required to be an `Error` — a `throw "string"` anywhere in a
|
|
* transport or its dependencies reaches here — and reading `.message` off one
|
|
* would put `undefined` where the diagnosis belongs.
|
|
*/
|
|
function reasonOf(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/** Result shape of one `resources/read`, narrowed to what a digest needs. */
|
|
interface ReadContents {
|
|
text?: string;
|
|
blob?: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
/**
|
|
* The block of a `resources/read` result that answers for `uri` — **selected by
|
|
* URI, never by position**.
|
|
*
|
|
* `contents` is an array, and taking `contents[0]` is wrong in the one way that
|
|
* matters here: these bytes are about to be hashed against `uri`'s advertised
|
|
* digest, so accepting a block the server labelled something else would verify
|
|
* one file's content against another file's digest — and could report that as
|
|
* `verified`. A false pass from a positional read is worse than a missing
|
|
* check, because it is an affirmative statement about a file nobody looked at.
|
|
*
|
|
* A **normalized** match is accepted, because a server may echo the URI back in
|
|
* a different but equivalent form — a resolved `..`, a percent-encoding
|
|
* difference. That is what `skillUriIdentity` is for, and it is the same rule
|
|
* the whole module applies to every other URI comparison, so a server cannot be
|
|
* treated as conforming by one check and non-conforming by another.
|
|
*
|
|
* `undefined` when nothing answers for the URI, which the caller reports as a
|
|
* read failure. This mirrors `onReadSkillFile` in the web client, deliberately:
|
|
* two code paths that hash bytes against a digest must not disagree about which
|
|
* bytes they are.
|
|
*/
|
|
function contentsFor(result: unknown, uri: string): ReadContents | undefined {
|
|
const contents = (result as { contents?: unknown })?.contents;
|
|
if (!Array.isArray(contents)) return undefined;
|
|
const wanted = skillUriIdentity(uri);
|
|
for (const block of contents) {
|
|
if (typeof block !== "object" || block === null) continue;
|
|
const got = (block as { uri?: unknown }).uri;
|
|
if (typeof got !== "string") continue;
|
|
if (skillUriIdentity(got) === wanted) return block as ReadContents;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Verify every skill in `entries` against the connected server.
|
|
*
|
|
* Never throws for a single skill or a single file: a report that aborted on
|
|
* the first unreadable file would hide every finding after it, and finding
|
|
* everything wrong in one pass is the entire value of running this in CI.
|
|
*
|
|
* ⚠️ **`AuthRecoveryRequiredError` is the deliberate exception and is re-thrown.**
|
|
* It is not a property of the file that happened to be in flight — it says the
|
|
* session's authorization expired, so every remaining read would fail the same
|
|
* way. Recording it per file would produce a report of N identical read
|
|
* failures and, worse, would swallow the one error a caller keys off to start a
|
|
* reauthorization: the TUI pane hands it to its recovery callback and the web
|
|
* commands retry after it. Absorbed here, the user is simply told the files
|
|
* could not be read, with no way offered to fix it.
|
|
*/
|
|
export async function verifySkills(
|
|
client: InspectorClientProtocol,
|
|
entries: readonly SkillEntry[],
|
|
metadata?: RequestMetadata,
|
|
): Promise<SkillVerifyReport[]> {
|
|
// Computed once over the whole set, because a name collision is a property
|
|
// of the listing rather than of an entry — `checkSkillConformance` sees one
|
|
// at a time and structurally cannot report it. Note this is scoped to the
|
|
// entries passed in, so `--method skills/get --verify` on a single skill
|
|
// reports no collision: there is no listing to collide within.
|
|
const collisions = checkSkillNameCollisions(entries);
|
|
const reports: SkillVerifyReport[] = [];
|
|
for (const entry of entries) {
|
|
// The entry's own SKILL.md, read once and used twice — for its digest and
|
|
// for the frontmatter cross-check. Reading it twice would double the load
|
|
// on the server and, worse, could compare a digest against one snapshot
|
|
// and frontmatter against another.
|
|
//
|
|
// Held as BYTES, not text. Taking `contents.text` skipped the whole
|
|
// frontmatter comparison whenever a server returned the markdown as a
|
|
// base64 `blob` — which is a legal `resources/read` shape, and which this
|
|
// module already decodes for the digest — so a mandatory check silently did
|
|
// not run while the report still said `ok` (Copilot). Deriving the text from
|
|
// the same verified bytes also guarantees the digest and the frontmatter
|
|
// describe one snapshot.
|
|
let entryBytes: Uint8Array | undefined;
|
|
const files: SkillFileReport[] = [];
|
|
|
|
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
|
|
// an equivalent form, so a raw string test would disagree with it and read
|
|
// the same file a second time.
|
|
const manifestListsSelf = manifest.some(
|
|
(resource) => skillUriIdentity(resource.uri) === entryIdentity,
|
|
);
|
|
for (const resource of manifest) {
|
|
let contents: ReadContents | undefined;
|
|
try {
|
|
const invocation = await client.readResource(resource.uri, metadata);
|
|
contents = contentsFor(invocation.result, resource.uri);
|
|
} catch (err) {
|
|
if (err instanceof AuthRecoveryRequiredError) throw err;
|
|
files.push({
|
|
uri: resource.uri,
|
|
status: "read-error",
|
|
reason: reasonOf(err),
|
|
});
|
|
continue;
|
|
}
|
|
if (!contents) {
|
|
files.push({
|
|
uri: resource.uri,
|
|
status: "read-error",
|
|
reason:
|
|
"resources/read returned no content block for this URI, so there are no bytes that can be checked against its digest.",
|
|
});
|
|
continue;
|
|
}
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = skillFileBytes(contents);
|
|
} catch (err) {
|
|
files.push({
|
|
uri: resource.uri,
|
|
status: "read-error",
|
|
reason: reasonOf(err),
|
|
});
|
|
continue;
|
|
}
|
|
if (skillUriIdentity(resource.uri) === entryIdentity) entryBytes = bytes;
|
|
const verification = await verifySkillResource(resource, bytes);
|
|
files.push({ uri: resource.uri, ...verification });
|
|
}
|
|
|
|
// A `"dynamic"` skill has no manifest, so the loop above read nothing —
|
|
// but its SKILL.md is still served and still has to match the frontmatter
|
|
// the listing advertised. That obligation is not waived by the file set
|
|
// being unenumerable; only integrity is. The same applies to a skill whose
|
|
// manifest omits its own file.
|
|
//
|
|
// 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. 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
|
|
// finding is a warning — so an unreadable SKILL.md returned `ok: true`
|
|
// for a skill whose mandatory frontmatter check never ran (Copilot).
|
|
const fail = (reason: string) =>
|
|
files.push({ uri: entry.uri, status: "read-error", reason });
|
|
try {
|
|
const invocation = await client.readResource(entry.uri, metadata);
|
|
const contents = contentsFor(invocation.result, entry.uri);
|
|
if (!contents) {
|
|
fail(
|
|
"resources/read returned no content block for this skill's own SKILL.md, so its frontmatter cannot be checked against the listing.",
|
|
);
|
|
} else {
|
|
try {
|
|
entryBytes = skillFileBytes(contents);
|
|
} catch (err) {
|
|
fail(reasonOf(err));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// An expired authorization is the one error that is not this file's
|
|
// problem — see the note on the function.
|
|
if (err instanceof AuthRecoveryRequiredError) throw err;
|
|
fail(reasonOf(err));
|
|
}
|
|
}
|
|
|
|
const entryText =
|
|
entryBytes === undefined ? undefined : bytesToText(entryBytes);
|
|
|
|
const collision = collisions.get(skillUriIdentity(entry.uri));
|
|
const conformance = [
|
|
...checkSkillConformance(entry),
|
|
...(collision ? [collision] : []),
|
|
];
|
|
const frontmatter =
|
|
entryText === undefined
|
|
? []
|
|
: checkSkillFrontmatterMatch(entry, entryText);
|
|
const hasError = [...conformance, ...frontmatter].some(
|
|
(issue) => issue.severity === "error",
|
|
);
|
|
const fileFailed = files.some(
|
|
(file) => file.status === "mismatch" || file.status === "read-error",
|
|
);
|
|
reports.push({
|
|
uri: entry.uri,
|
|
name: skillDisplayName(entry),
|
|
conformance,
|
|
frontmatter,
|
|
files,
|
|
ok: !hasError && !fileFailed,
|
|
});
|
|
}
|
|
return reports;
|
|
}
|
|
|
|
/** True when every skill in the report passed. */
|
|
export function allSkillsVerified(
|
|
reports: readonly SkillVerifyReport[],
|
|
): boolean {
|
|
return reports.every((report) => report.ok);
|
|
}
|