inspector/core/mcp/skillsVerification.ts
cliffhall 9117f633aa fix: address Copilot review round 17 on #2248
Four of the five findings share one root cause: the tri-state `outcome`
added in round 15 was not propagated to consumers, which kept branching
on `ok` — true for an `incomplete` report, since nothing that was checked
was wrong.

- CLI `summarizeSkillVerification` counts off `outcome`, not `ok`, so a
  truncated walk no longer prints "no conformance errors" one line before
  exiting SKILL_INCOMPLETE. A mixed failed/incomplete catalog reports
  both counts rather than letting the louder verdict hide the quieter.
- `verifySkills` sets the truncation reason only when entries were
  actually left unread. A file crossing the byte budget as the FINAL
  entry stopped nothing, and "Stopped after 3 of 3" both read as a
  contradiction and demoted a fully-read skill out of `verified`.
- The TUI status line switches on `outcome` through a `Record` over the
  union, so the INCOMPLETE arm is reachable and a fourth outcome would
  be a type error rather than a silently missing label.
- The `incomplete` doc paragraph said it makes `ok` false. It does not,
  deliberately — corrected, and it now names `outcome` as the thing a
  consumer must branch on.
- The web sidebar composes in the per-URI `duplicate-name` warning, so
  two colliding skills are badged in the catalog instead of looking
  clean until one is selected.

The suppressed `selfAttempted` finding is stale — fixed in round 16.

The existing TUI INCOMPLETE test was passing for the wrong reason: its
fixture understated every size, which is itself a mismatch, so the report
was `failed` and only the old `ok`-first branch printed INCOMPLETE. It is
rebuilt with honest digests and sizes, so truncation comes from the
declared-size prefilter and the report is genuinely incomplete.

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>
2026-09-07 23:12:28 -04:00

490 lines
22 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,
SKILL_MAX_TOTAL_BYTES,
skillFileBytes,
skillUriIdentity,
verifySkillResource,
type SkillIssue,
type SkillVerification,
} from "./skills.js";
import {
DYNAMIC_RESOURCES,
type SkillEntry,
type SkillResource,
} 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` entries **and**
* `SKILL_MAX_TOTAL_BYTES` of declared content. A manifest over either bound
* is already reported by `resource-limit-exceeded` / `size-limit-exceeded`,
* and reading it anyway would let a server dictate unbounded round trips or
* bandwidth — 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[];
/**
* Why the manifest could not be checked in full, or `undefined` when it was.
*
* Set when the read bounds truncated the manifest, and **only** when entries
* were actually left unread — a file that crosses the byte budget as the
* last entry checked nothing short, so it is not incomplete.
*
* ⚠️ It does **not** make {@link ok} false. `ok` keeps the narrow meaning of
* "nothing that was checked is wrong", and a truncated walk checked nothing
* that was wrong. The signal a consumer must branch on is {@link outcome}
* being `"incomplete"`, never `!ok` — a manifest whose unread 513th file was
* tampered with reports `ok: true`, and a consumer that prints "verified" on
* `ok` alone turns a denial of service into a false pass (Copilot). Both the
* CLI summary and the TUI status line had exactly that bug.
*/
incomplete?: string;
/**
* What the verification concluded. **Three outcomes, not two**, because
* "this skill is wrong" and "this skill could not be fully checked" are
* different answers and collapsing them misreports one of them:
*
* - `verified` — everything was checked and everything passed.
* - `failed` — something the SEP makes a MUST was broken.
* - `incomplete` — nothing checked was wrong, but the read bounds stopped
* the walk before it finished. See {@link incomplete} for the reason.
*
* ⚠️ The `incomplete` case exists because both of the obvious two-state
* answers are wrong. Reporting success would be a **false pass** for a
* manifest whose unread 513th file is tampered with. Reporting failure
* would call a server nonconformant for exceeding limits SEP-2640 states as
* SHOULD NOT — with hosts free to support more — which contradicts this
* module's own rule that a warning never fails a report (Copilot).
*/
outcome: "verified" | "failed" | "incomplete";
/**
* 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. Neither does {@link incomplete}: an unfinished
* walk is reported through {@link outcome}, so `ok` keeps its narrow meaning
* of "nothing that was checked is wrong".
*/
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);
}
/**
* The prefix of a manifest that may be read: at most
* {@link SKILL_MAX_RESOURCE_ENTRIES} entries, and at most
* {@link SKILL_MAX_TOTAL_BYTES} of declared content.
*/
function boundedManifest(
declared: readonly SkillResource[],
): readonly SkillResource[] {
const kept: SkillResource[] = [];
let bytes = 0;
for (const resource of declared) {
if (kept.length >= SKILL_MAX_RESOURCE_ENTRIES) break;
// Only a usable, non-negative size counts — matching `totalSkillBytes`, so
// the bound and the finding that reports the overage agree on the total.
const size =
typeof resource.size === "number" &&
Number.isSafeInteger(resource.size) &&
resource.size >= 0
? resource.size
: 0;
if (bytes + size > SKILL_MAX_TOTAL_BYTES) break;
bytes += size;
kept.push(resource);
}
return kept;
}
/** 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 on BOTH interoperability limits, because the manifest is
// server-controlled.** `checkSkillConformance` reports when either is
// exceeded — as warnings, since the SEP makes them bounds rather than MUSTs
// — but checking constrains nothing, and this loop then downloads whatever
// was advertised anyway.
//
// The entry count alone is not enough: a manifest can sit at exactly 512
// entries and declare a gigabyte each, so bounding only the count still let
// a server dictate unbounded bandwidth and time (Copilot). Both overages
// are reported by `resource-limit-exceeded` / `size-limit-exceeded`; what
// stops here is *reading* them.
//
// Sizes are summed the way `totalSkillBytes` sums them — an entry
// declaring none contributes nothing, which cannot overstate the total and
// is bounded by the count cap regardless. A read is skipped only when the
// running total would CROSS the limit, so a conforming skill (≤ 16 MiB in
// total, by definition) is never truncated.
const manifest = boundedManifest(declared);
let incomplete =
manifest.length < declared.length
? `Only ${manifest.length} of ${declared.length} manifest entries were read: the skill exceeds the ${SKILL_MAX_RESOURCE_ENTRIES}-entry / ${SKILL_MAX_TOTAL_BYTES}-byte interoperability limits, so the rest were not fetched and cannot be reported on.`
: undefined;
// ⚠️ Bytes ACTUALLY RECEIVED, which is the only budget a server cannot
// lie its way past. `boundedManifest` above works from DECLARED sizes, and
// those are server-controlled — a manifest advertising `size: 1` (or, since
// the wire schema deliberately accepts it for reporting, no size at all)
// sailed through the declared budget and then served arbitrarily large
// bodies, defeating the 16 MiB safeguard entirely (Copilot). The declared
// prefilter still earns its place by refusing to *schedule* an obviously
// oversized set; this is what stops one that lied.
let receivedBytes = 0;
const entryIdentity = skillUriIdentity(entry.uri);
// ⚠️ Whether the self entry was **actually reached**, not merely whether
// the bounded slice contains it. The byte budget can break the loop before
// a later self-entry, and a flag describing the slice stayed true — which
// suppressed the fallback and skipped the mandatory frontmatter check
// entirely (Copilot). Set inside the loop, so it can only be true of a row
// the walk got to.
//
// 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.
let selfAttempted = false;
for (const [index, resource] of manifest.entries()) {
if (skillUriIdentity(resource.uri) === entryIdentity) {
// Marked before the read, not after: a row the walk reached but could
// not read has still been attempted, and its failure is recorded here
// rather than re-attempted by the fallback.
selfAttempted = true;
}
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 });
// Counted AFTER verifying this file, so the one that crosses the line is
// still reported rather than fetched and discarded. The next read is what
// stops. ⚠️ This bounds the total across responses, not the size of any
// single one: a first response larger than the cap is already in memory
// by the time it can be measured, which would need a streaming read to
// prevent and is not something this API exposes.
receivedBytes += bytes.byteLength;
if (receivedBytes > SKILL_MAX_TOTAL_BYTES) {
// ⚠️ Only *incomplete* when the budget actually cost a read. Crossing
// the line on the final entry stopped nothing — every manifest row was
// fetched and checked — and reporting "Stopped after 3 of 3" there
// both reads as a contradiction and demotes a fully-checked skill out
// of `verified` (Copilot). The prefilter's own reason, if it dropped
// entries before the walk, is already set and is not overwritten.
if (index < manifest.length - 1) {
incomplete = `Stopped after ${index + 1} of ${declared.length} manifest entries: the files actually served exceed the ${SKILL_MAX_TOTAL_BYTES}-byte interoperability limit, whatever sizes the manifest declared.`;
}
break;
}
}
// 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 `selfAttempted` 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. A self-entry the walk never reached, whether
// because a cap excluded it or because the byte budget broke the loop
// first, still gets the fallback: the frontmatter comparison is mandatory
// and must not be lost to a limit that exists to bound unrelated files.
if (!selfAttempted) {
// 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));
}
}
// If the DECLARED manifest lists this file but the read bounds
// excluded it, verify it here too. The fallback exists for the
// frontmatter check, but reading a file and then not checking the
// digest the manifest advertised for it would leave the entry's own
// SKILL.md the one file nobody verified (Copilot).
const declaredSelf = declared.find(
(resource) => skillUriIdentity(resource.uri) === entryIdentity,
);
if (declaredSelf && entryBytes !== undefined) {
files.push({
// The DECLARED spelling, not the entry's. They can differ — a
// manifest may write its self-entry in a normalized-equivalent
// form — and a consumer matching rows against the manifest then
// finds nothing, while a normalized "extra files" filter suppresses
// it as already covered. The result was a verdict that existed in
// the report and appeared nowhere on screen (Copilot).
uri: declaredSelf.uri,
...(await verifySkillResource(declaredSelf, entryBytes)),
});
}
} 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,
...(incomplete ? { incomplete } : {}),
ok: !hasError && !fileFailed,
outcome:
hasError || fileFailed
? "failed"
: incomplete !== undefined
? "incomplete"
: "verified",
});
}
return reports;
}
/**
* True when every skill was checked in full and passed.
*
* Deliberately stricter than `every(r => r.ok)`: a report that could not be
* finished has not verified anything about the part it did not read, so it is
* not "verified" even though nothing it *did* read was wrong.
*/
export function allSkillsVerified(
reports: readonly SkillVerifyReport[],
): boolean {
return reports.every((report) => report.outcome === "verified");
}
/** True when any skill broke something the SEP makes a MUST. */
export function anySkillFailed(reports: readonly SkillVerifyReport[]): boolean {
return reports.some((report) => report.outcome === "failed");
}