fix: address Copilot review round 18 on #2248

Three findings, all in paths hardened by earlier rounds.

- The 16 MiB read budget was charged only after a content block had
  matched by URI *and* decoded. A server could answer every manifest row
  with one enormous block labelled a different URI (so `contentsFor`
  found nothing) or one enormous invalid-base64 blob (so `skillFileBytes`
  threw), bank zero against the cap, and have the walk issue up to 512
  more. `responseBytes` now charges the raw response before selection or
  decoding; the exact decoded length is substituted when there is one, so
  the common path is still measured precisely.

- The Directory section checked that a child was inside the skill root
  but not that it was a child of the directory actually being read. A
  grandchild, or the directory echoing itself back, passed containment
  and was rendered as navigable. Non-direct entries are now shown and
  labelled rather than linked — `resources/directory/read` answers with
  direct children, so an entry that is not one is itself the finding.

- Containment was decided on the normalized URI while navigation sent and
  stored the raw one. For `skill://r/a/../templates` the first Up produced
  `skill://r/a/..` and a second walked into `skill://r/a`, a directory
  nothing had validated. Directory descent passes the normalized URI, and
  the Up arithmetic moved into `parentOfSkillUri`, documented as valid
  only on a normalized URI.

The suppressed comment (two stderr lines on a failing `--verify`) is
declined again: `--strict` produces the same shape in `emit-result.ts`,
and clients/cli/README.md already documents it as such.

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 23:33:30 -04:00
commit a3f36ce863

View file

@ -219,6 +219,37 @@ function contentsFor(result: unknown, uri: string): ReadContents | undefined {
return undefined;
}
/**
* What a `resources/read` response cost to receive, charged against the byte
* budget regardless of whether any of it is usable.
*
* ⚠️ Deliberately measured on the RAW result rather than on decoded content.
* The budget exists to stop a server from making this walk transfer unbounded
* data, and a server that wants to do that has two free routes if only decoded
* bytes are counted: label an enormous block with a URI that was not asked for,
* or send an enormous blob that is not valid base64. Both leave the decode
* paths empty-handed while the bytes have already crossed the wire.
*
* The figure is an **approximation, and deliberately never an undercount by
* more than a small factor**: `text` is charged in UTF-16 code units (UTF-8 is
* between 1× and 3× that for the same string) and `blob` in base64 characters
* (roughly 4/3 of the bytes it decodes to, so an overcharge). Exactness is not
* the point — this is a safety limit, not an accounting figure, and the caller
* substitutes the exact decoded length whenever it has one.
*/
function responseBytes(result: unknown): number {
const contents = (result as { contents?: unknown })?.contents;
if (!Array.isArray(contents)) return 0;
let total = 0;
for (const block of contents) {
if (typeof block !== "object" || block === null) continue;
const { text, blob } = block as { text?: unknown; blob?: unknown };
if (typeof text === "string") total += text.length;
if (typeof blob === "string") total += blob.length;
}
return total;
}
/**
* Verify every skill in `entries` against the connected server.
*
@ -316,49 +347,69 @@ export async function verifySkills(
// rather than re-attempted by the fallback.
selfAttempted = true;
}
let contents: ReadContents | undefined;
// Bytes attributed to THIS response, charged whether or not any of them
// turn out to be usable — see `responseBytes`.
let charged = 0;
try {
const invocation = await client.readResource(resource.uri, metadata);
contents = contentsFor(invocation.result, resource.uri);
// ⚠️ Charged from the RAW response, BEFORE the block is selected and
// before it is decoded. Charging only the decoded bytes let a server
// spend the budget for free: return one enormous block labelled some
// other URI (so `contentsFor` finds nothing) or one enormous invalid
// base64 blob (so `skillFileBytes` throws), and the walk banked zero
// against the cap and went on to issue up to 512 more of them
// (Copilot). The safeguard has to be paid for by the transfer, not by
// the parse.
charged = responseBytes(invocation.result);
const contents = contentsFor(invocation.result, resource.uri);
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.",
});
} else {
let bytes: Uint8Array | undefined;
try {
bytes = skillFileBytes(contents);
} catch (err) {
files.push({
uri: resource.uri,
status: "read-error",
reason: reasonOf(err),
});
}
if (bytes) {
// The decoded length is exact where `responseBytes` is only an
// estimate, so the larger of the two is charged: never less than
// what this file actually cost, and never less than what the rest
// of the response was estimated to cost.
charged = Math.max(charged, bytes.byteLength);
if (skillUriIdentity(resource.uri) === entryIdentity)
entryBytes = bytes;
const verification = await verifySkillResource(resource, bytes);
files.push({ uri: resource.uri, ...verification });
}
}
} catch (err) {
if (err instanceof AuthRecoveryRequiredError) throw err;
// Nothing to charge: a rejected read never handed us a payload to
// measure. Whatever the transport moved before failing is invisible
// at this layer.
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;
// Counted AFTER this row is recorded, so the response 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 += charged;
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