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:
parent
9117f633aa
commit
a3f36ce863
1 changed files with 253 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue