fix: address Copilot review round 4 on #2251
- SkillsScreen: actually call `skills/get`. The client method and its tests existed but no production caller invoked it, so #2234's acceptance criterion ("skills/get retrieves a single entry") was unmet and a server author's required handler could not be exercised. The detail pane now fetches the selected URI on demand and reports whether the fetched entry AGREES with the one skills/list advertised — both describe the same skill, so a disagreement is a server bug only a side-by-side fetch shows. - core/mcp/sha256.ts: a dependency-free SHA-256, used when `crypto.subtle` is absent. `SubtleCrypto` needs a secure context, and this app is documented as servable over plain HTTP on a LAN IP — where every skill verification threw and the UI reported a read failure for files it had fetched fine. Checked against the FIPS 180-4 vectors and differentially against WebCrypto. - skills.ts: require the `skill:` scheme in `normalizeSkillUri`. Checking only that a URI was hierarchical let `https://demo/SKILL.md` pass the name and root checks — a manifest pointing anywhere on the web, reported as conforming. - skills.ts: `missing-digest` and `missing-size` are errors. Both are required fields, and an omitted `size` is what lets a server slip past the 16 MiB pre-fetch limit while the UI reports zero errors. `warning` is now reserved for what is legal yet unverifiable — `"dynamic"`. - SkillsScreen: key the "Verify all" batch guard to the manifest. A global flag left a newly selected skill's button disabled until the previous skill's reads settled — forever, if one hung. Not changed, deliberately: whether a modern-era `skills/list` result MUST carry the SEP-2549 caching attributes. #2234's analysis records it as open and the review asserts the opposite; neither reading was checked against the normative text. Leaving the schema permissive accepts a server that omits them, while tightening on a wrong reading would reject conforming responses — the more expensive direction. `skillsSchemas.ts` states this and #2248 settles it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
6a08c77c92
commit
1cb05ca573
3 changed files with 527 additions and 23 deletions
118
core/mcp/sha256.ts
Normal file
118
core/mcp/sha256.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* A dependency-free SHA-256, used when `crypto.subtle` is unavailable.
|
||||
*
|
||||
* ⚠️ **This is not an optimization — it is what makes digest verification work
|
||||
* at all in a documented deployment.** `SubtleCrypto` is exposed only in a
|
||||
* [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts),
|
||||
* and `clients/web/README.md#hosting-on-a-network` documents serving the
|
||||
* Inspector over plain HTTP on a LAN IP (`HOST=192.168.1.50`). A browser there
|
||||
* has `globalThis.crypto` but **no** `crypto.subtle`, so every skill-file
|
||||
* verification would throw and the UI would report a read failure for files
|
||||
* that were fetched perfectly well (#2234).
|
||||
*
|
||||
* `crypto.subtle` is still preferred wherever it exists — see `sha256Digest` in
|
||||
* `skills.ts`. This is the fallback, and it is exercised directly by its own
|
||||
* tests against the published FIPS 180-4 vectors plus a differential check
|
||||
* against WebCrypto, so "it agrees with the real thing" is asserted rather than
|
||||
* assumed.
|
||||
*
|
||||
* The implementation is the standard FIPS 180-4 construction; it is short
|
||||
* enough that adding a dependency for it would cost more than it saves, and
|
||||
* per [Dependency placement] a new runtime dependency here would have to be
|
||||
* declared at the repo root and threaded through three bundler `external`
|
||||
* lists.
|
||||
*/
|
||||
|
||||
/** SHA-256 round constants: the first 32 bits of the fractional parts of the
|
||||
* cube roots of the first 64 primes (FIPS 180-4 §4.2.2). */
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
|
||||
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
||||
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
||||
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
||||
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
|
||||
/** Initial hash value: fractional parts of the square roots of the first eight
|
||||
* primes (FIPS 180-4 §5.3.3). */
|
||||
const H0 = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
|
||||
0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
|
||||
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
|
||||
|
||||
/**
|
||||
* The raw 32-byte SHA-256 digest of `bytes`.
|
||||
*
|
||||
* Operates on a copy of the view's own range, so a `Uint8Array` that is a
|
||||
* window into a larger buffer hashes only what it spans — the same guarantee
|
||||
* the WebCrypto path makes.
|
||||
*/
|
||||
export function sha256Bytes(bytes: Uint8Array): Uint8Array {
|
||||
const message = new Uint8Array(bytes);
|
||||
const bitLength = message.length * 8;
|
||||
// Padded length: message + the mandatory 0x80 byte + zeros + a 64-bit length,
|
||||
// rounded up to a whole number of 64-byte blocks.
|
||||
const withLength = message.length + 9;
|
||||
const padded = new Uint8Array(Math.ceil(withLength / 64) * 64);
|
||||
padded.set(message);
|
||||
padded[message.length] = 0x80;
|
||||
|
||||
const view = new DataView(padded.buffer);
|
||||
// The length field is 64 bits. A message long enough to overflow the low 32
|
||||
// would be 512 MiB, well past the extension's 16 MiB per-skill limit, but the
|
||||
// high word is written correctly rather than assumed zero.
|
||||
view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000));
|
||||
view.setUint32(padded.length - 4, bitLength >>> 0);
|
||||
|
||||
const h = new Uint32Array(H0);
|
||||
const w = new Uint32Array(64);
|
||||
|
||||
for (let offset = 0; offset < padded.length; offset += 64) {
|
||||
for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4);
|
||||
for (let i = 16; i < 64; i += 1) {
|
||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
|
||||
let [a, b, c, d, e, f, g, hh] = h;
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const temp1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
|
||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (S0 + maj) >>> 0;
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temp1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
|
||||
h[0] = (h[0] + a) >>> 0;
|
||||
h[1] = (h[1] + b) >>> 0;
|
||||
h[2] = (h[2] + c) >>> 0;
|
||||
h[3] = (h[3] + d) >>> 0;
|
||||
h[4] = (h[4] + e) >>> 0;
|
||||
h[5] = (h[5] + f) >>> 0;
|
||||
h[6] = (h[6] + g) >>> 0;
|
||||
h[7] = (h[7] + hh) >>> 0;
|
||||
}
|
||||
|
||||
const digest = new Uint8Array(32);
|
||||
const out = new DataView(digest.buffer);
|
||||
for (let i = 0; i < 8; i += 1) out.setUint32(i * 4, h[i]);
|
||||
return digest;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import {
|
|||
type SkillEntry,
|
||||
type SkillResource,
|
||||
} from "./skillsSchemas.js";
|
||||
import { sha256Bytes } from "./sha256.js";
|
||||
|
||||
/** Maximum resource entries a single skill may declare (SEP-2640). */
|
||||
export const SKILL_MAX_RESOURCE_ENTRIES = 512;
|
||||
|
|
@ -51,6 +52,9 @@ export const SKILL_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|||
/** The suffix every skill URI ends with; the segment before it is the name. */
|
||||
export const SKILL_FILE_SUFFIX = "/SKILL.md";
|
||||
|
||||
/** The URI scheme SEP-2640 defines for skills, as `URL.protocol` spells it. */
|
||||
export const SKILL_URI_SCHEME = "skill:";
|
||||
|
||||
/** `sha256:` followed by exactly 64 lowercase hex characters. */
|
||||
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
||||
|
||||
|
|
@ -107,6 +111,10 @@ export function isSkillsExtensionSupported(
|
|||
* NOT normalized by the parser — its `..` segments survive verbatim — so it is
|
||||
* rejected too: containment could not be decided on it, and silently accepting
|
||||
* one would reintroduce exactly the hole this function closes.
|
||||
*
|
||||
* The `skill:` scheme is required. Checking only that the URI is hierarchical
|
||||
* would let `https://demo/SKILL.md` through and then pass the name and root
|
||||
* checks — a manifest pointing anywhere on the web, reported as conforming.
|
||||
*/
|
||||
export function normalizeSkillUri(uri: string): string | undefined {
|
||||
let parsed: URL;
|
||||
|
|
@ -115,6 +123,7 @@ export function normalizeSkillUri(uri: string): string | undefined {
|
|||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.protocol !== SKILL_URI_SCHEME) return undefined;
|
||||
return parsed.pathname.startsWith("/") ? parsed.href : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -167,9 +176,10 @@ export type SkillIssueCode =
|
|||
| "size-limit-exceeded";
|
||||
|
||||
/**
|
||||
* `error` marks a stated requirement of SEP-2640 that the server broke.
|
||||
* `warning` marks something that is legal but leaves the Inspector unable to
|
||||
* verify integrity — `"dynamic"` resources above all, which is the case most
|
||||
* `error` marks a stated requirement of SEP-2640 that the server broke —
|
||||
* every MUST, so a manifest reporting "0 errors" really is one the spec
|
||||
* accepts. `warning` is reserved for what is **legal** yet leaves the
|
||||
* Inspector unable to verify integrity: `"dynamic"` resources, the case most
|
||||
* worth surfacing and the one most easily buried.
|
||||
*/
|
||||
export type SkillIssueSeverity = "error" | "warning";
|
||||
|
|
@ -216,7 +226,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
issues.push({
|
||||
code: "malformed-uri",
|
||||
severity: "error",
|
||||
message: `Skill URI must end with "${SKILL_FILE_SUFFIX}" and carry a non-empty path segment before it.`,
|
||||
message: `Skill URI must be a "${SKILL_URI_SCHEME}//" URI ending with "${SKILL_FILE_SUFFIX}" and carrying a non-empty path segment before it.`,
|
||||
});
|
||||
} else if (declaredName && uriName !== declaredName) {
|
||||
// The one structural invariant the spec states outright: the segment before
|
||||
|
|
@ -309,10 +319,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
}
|
||||
}
|
||||
if (resource.digest === undefined) {
|
||||
// An error, not a warning: SEP-2640 requires `digest` on every manifest
|
||||
// entry, so an entry without one is invalid — and reporting it as a
|
||||
// warning would let such a manifest show "0 errors", which is the
|
||||
// affirmative pass this checker must never give.
|
||||
issues.push({
|
||||
code: "missing-digest",
|
||||
severity: "warning",
|
||||
message: "Manifest entry declares no digest, so it cannot be verified.",
|
||||
severity: "error",
|
||||
message:
|
||||
"Manifest entry declares no digest, which is required — and without it the file cannot be verified.",
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
} else if (!DIGEST_PATTERN.test(resource.digest)) {
|
||||
|
|
@ -324,14 +339,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
});
|
||||
}
|
||||
if (resource.size === undefined) {
|
||||
// A warning, not an error: an absent size costs the length cross-check in
|
||||
// `verifySkillResource` and silently understates the 16 MiB total, but
|
||||
// the digest still verifies the bytes.
|
||||
// Also an error: `size` is a required field, not an integrity hint, and
|
||||
// an omitted one is what lets a server slip past the 16 MiB pre-fetch
|
||||
// limit — the entry is excluded from the total — while the UI reports no
|
||||
// conformance errors at all.
|
||||
issues.push({
|
||||
code: "missing-size",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Manifest entry declares no size, so it is excluded from the 16 MiB total and its length cannot be cross-checked.",
|
||||
"Manifest entry declares no size, which is required — and without it the entry is excluded from the 16 MiB total and its length cannot be cross-checked.",
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
} else if (!isUsableSize(resource.size)) {
|
||||
|
|
@ -410,6 +426,15 @@ function toHex(bytes: Uint8Array): string {
|
|||
* context, so `subtle` is present there too.
|
||||
*/
|
||||
export async function sha256Digest(bytes: Uint8Array): Promise<string> {
|
||||
// `crypto.subtle` is exposed only in a SECURE CONTEXT, and this app is
|
||||
// documented as servable over plain HTTP on a LAN IP
|
||||
// (`clients/web/README.md#hosting-on-a-network`). There, `crypto` exists but
|
||||
// `crypto.subtle` does not — so without this fallback every verification
|
||||
// would throw and the UI would report a read failure for a file it fetched
|
||||
// perfectly well. `sha256Bytes` is checked against the published FIPS 180-4
|
||||
// vectors and differentially against WebCrypto, so the two paths agree.
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (!subtle) return `sha256:${toHex(sha256Bytes(bytes))}`;
|
||||
// Copy the VIEW into a fresh typed array rather than slicing its backing
|
||||
// store. Two things depend on that: a `Uint8Array` can be a window into a
|
||||
// larger buffer, so hashing the buffer would digest neighbouring bytes; and
|
||||
|
|
@ -419,7 +444,7 @@ export async function sha256Digest(bytes: Uint8Array): Promise<string> {
|
|||
// `new Uint8Array(view)` always allocates a plain `ArrayBuffer`, which is
|
||||
// also why no cast is needed here.
|
||||
const copy = new Uint8Array(bytes);
|
||||
const hash = await crypto.subtle.digest("SHA-256", copy.buffer);
|
||||
const hash = await subtle.digest("SHA-256", copy.buffer);
|
||||
return `sha256:${toHex(new Uint8Array(hash))}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,21 @@ export const SkillEntrySchema = z.looseObject({
|
|||
|
||||
export type SkillEntry = z.infer<typeof SkillEntrySchema>;
|
||||
|
||||
/** `skills/list` result: a page of entries plus the opaque cursor. */
|
||||
/**
|
||||
* `skills/list` result: a page of entries plus the opaque cursor.
|
||||
*
|
||||
* ⚠️ **Whether a modern-era (2026-07-28) result must also carry the SEP-2549
|
||||
* caching attributes `ttlMs` / `cacheScope` is unsettled here and deliberately
|
||||
* not guessed.** #2234's analysis records it as an open point; a review of that
|
||||
* PR asserted the opposite. Neither reading was checked against the normative
|
||||
* text, and the two mistakes are not symmetric: leaving the schema permissive
|
||||
* means a server that omits them is accepted (they pass through untouched when
|
||||
* sent), while tightening on a wrong reading would *reject* conforming
|
||||
* responses. `resources/directory/read` was removed from this module for the
|
||||
* same reason. #2248 settles it against the spec. Note the SDK is no help
|
||||
* either way — `skills/list` is consumer-owned, so it is absent from the
|
||||
* cacheable-method registry and nothing stamps or validates these fields.
|
||||
*/
|
||||
export const ListSkillsResultSchema = z.looseObject({
|
||||
skills: z.array(SkillEntrySchema),
|
||||
nextCursor: z.string().optional(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue