fix: address Copilot review round 13 on #2248

**The byte budget trusted server-declared sizes.** 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. The 16 MiB safeguard protected against an honest
server only, which is no safeguard.

The walk now tracks bytes ACTUALLY received and stops issuing reads once the
real total crosses the cap, marking the report `incomplete` and so `ok:
false`. The declared prefilter is kept rather than replaced: it refuses to
schedule an obviously oversized set, and the received-byte counter stops one
that lied. The count happens after verifying the crossing file, so that file
is still reported rather than fetched and discarded.

⚠️ This bounds the total across responses, not any single one: a first
response larger than the cap is already in memory before it can be measured,
which would need a streaming read the client API does not expose. Stated in
the code rather than left to look closed.

**A modern `skills/get` did not require `resultType`.** The "left open"
question SEP-2640 states covers `ttlMs` / `cacheScope` and only those;
`resultType` is base-protocol per SEP-2322 and appears in the SEP's own
`skills/get` example. Requiring it of `resources/directory/read` but not here
was an inconsistency in the module rather than a distinction the spec draws.
Era-selected now, with the caching attributes still optional.

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 22:09:19 -04:00
commit 4f12d07d2b
3 changed files with 156 additions and 3 deletions

View file

@ -388,6 +388,34 @@ describe("InspectorClient skills methods (#2234)", () => {
});
});
it("requires resultType on a modern skills/get", async () => {
// Base-protocol (SEP-2322) and present in SEP-2640's own example, unlike
// the caching attributes the SEP leaves open.
const client = makeClient();
internals(client).protocolEra = "modern";
stubRequest(client, { skill: ENTRY });
await expect(
client.getSkill("skill://demo/SKILL.md"),
).rejects.toBeDefined();
});
it("accepts a modern skills/get without the caching attributes", async () => {
const client = makeClient();
internals(client).protocolEra = "modern";
stubRequest(client, { skill: ENTRY, resultType: "complete" });
await expect(client.getSkill("skill://demo/SKILL.md")).resolves.toEqual(
ENTRY,
);
});
it("accepts a legacy skills/get without resultType", async () => {
const client = makeClient();
stubRequest(client, { skill: ENTRY });
await expect(client.getSkill("skill://demo/SKILL.md")).resolves.toEqual(
ENTRY,
);
});
it("rejects a skills/list result that is not a skills page", async () => {
// The explicit result schema is the whole client-side mechanism for a
// consumer-owned extension method, so a nonconforming result must fail

View file

@ -5,7 +5,9 @@ import {
ModernDirectoryReadResultSchema,
RESOURCES_DIRECTORY_READ_METHOD,
DYNAMIC_RESOURCES,
GetSkillEnvelopeSchema,
GetSkillResultSchema,
ModernGetSkillEnvelopeSchema,
ListSkillsResultSchema,
ModernListSkillsResultSchema,
SKILLS_EXTENSION_KEY,
@ -283,4 +285,28 @@ describe("GetSkillResultSchema caching attributes (#2248)", () => {
}).success,
).toBe(true);
});
it("requires resultType on the modern envelope, but still not the caching fields", () => {
// "Left open" covers `ttlMs` / `cacheScope` and only those. `resultType` is
// base-protocol (SEP-2322) and appears in SEP-2640's own `skills/get`
// example, so leaving it optional here while requiring it of
// `resources/directory/read` was an inconsistency in this module rather
// than a distinction the spec draws (Copilot).
expect(
ModernGetSkillEnvelopeSchema.safeParse({ skill: ENTRY }).success,
).toBe(false);
expect(
ModernGetSkillEnvelopeSchema.safeParse({
skill: ENTRY,
resultType: "complete",
}).success,
).toBe(true);
});
it("keeps the legacy envelope permissive about resultType", () => {
// A 2026-era member a legacy server has no business sending.
expect(GetSkillEnvelopeSchema.safeParse({ skill: ENTRY }).success).toBe(
true,
);
});
});

View file

@ -647,6 +647,61 @@ describe("verifySkills (#2248)", () => {
expect(self?.expectedDigest).toBe(`sha256:${"b".repeat(64)}`);
});
it("stops on bytes ACTUALLY served, not the sizes the manifest declared", async () => {
// The declared budget is server-controlled: advertising `size: 1` and then
// serving megabytes sailed straight through it, defeating the 16 MiB
// safeguard entirely (Copilot).
const big = "x".repeat(6 * 1024 * 1024);
const skill: SkillEntry = {
uri: "skill://liar/SKILL.md",
frontmatter: { name: "liar", description: "Understates its sizes" },
resources: Array.from({ length: 10 }, (_, i) => ({
uri: i === 0 ? "skill://liar/SKILL.md" : `skill://liar/f${i}.md`,
digest: `sha256:${"a".repeat(64)}`,
size: 1, // a lie
})),
};
const readResource = vi.fn(async (uri: string) => ({
result: { contents: [{ uri, text: big }] },
}));
const client = { readResource } as unknown as InspectorClientProtocol;
const [report] = await verifySkills(client, [skill]);
// Three 6 MiB bodies cross 16 MiB; the walk stops rather than reading ten.
expect(readResource).toHaveBeenCalledTimes(3);
expect(report.incomplete).toMatch(/actually served/);
expect(report.ok).toBe(false);
});
it("still reports the file that crossed the byte budget", async () => {
// The crossing file is verified before the walk stops, so its verdict is
// not fetched and then thrown away.
const big = "x".repeat(17 * 1024 * 1024);
const skill: SkillEntry = {
uri: "skill://liar/SKILL.md",
frontmatter: { name: "liar", description: "One enormous file" },
resources: [
{
uri: "skill://liar/SKILL.md",
digest: `sha256:${"a".repeat(64)}`,
size: 1,
},
{
uri: "skill://liar/b.md",
digest: `sha256:${"a".repeat(64)}`,
size: 1,
},
],
};
const readResource = vi.fn(async (uri: string) => ({
result: { contents: [{ uri, text: big }] },
}));
const client = { readResource } as unknown as InspectorClientProtocol;
const [report] = await verifySkills(client, [skill]);
expect(readResource).toHaveBeenCalledTimes(1);
expect(report.files).toHaveLength(1);
expect(report.files[0].status).toBe("mismatch");
});
it("does not truncate a conforming manifest", async () => {
// A conforming skill totals at most 16 MiB by definition, so the bound
// must never shorten one — otherwise it would trade a hostile-server