fix: address Copilot review round 9 on #2248

**Verification reads were unbounded.** The 512-entry limit is checked by
`checkSkillConformance` — as a warning, since the SEP makes it an
interoperability bound rather than a MUST — but checking it constrains
nothing, so a hostile server advertising a million entries had the tool
perform a million sequential reads after the report already knew the manifest
was over. Capped at `SKILL_MAX_RESOURCE_ENTRIES`, with the finding still
reported so bounding the work does not silence the reason it was
unnecessary. `manifestListsSelf` is computed over the READ slice, so a
self-entry pushed past the cap still reaches the fallback: the frontmatter
comparison is mandatory and must not be lost to a limit that bounds other
files.

**A malformed skill URI still produced a directory root.** `skillUriIdentity`
falls back to the raw string, so `not a uri/SKILL.md` yielded the root `not a
uri` and enabled the Directory section — letting the UI build a request from
a URI the conformance checks had already rejected. The comment above it
claimed otherwise. `normalizeSkillUri` now, with the rule written down:
identity is for COMPARING two spellings, not for deciding a URI is
well-formed enough to build a request from.

The existing regression test passed against a weaker input that failed for
lacking the suffix and never reached the fallback; it is a table of three
shapes now.

**The test-server guide overstated its own coverage,** claiming four of the
five conformance scenarios map onto fixtures. Three do: there is no
size-mismatch fixture, because the fixture can override an advertised digest
and nothing else, so that path is covered by unit tests. Corrected, with what
adding one would take.

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 21:21:49 -04:00
commit 88c1b89575
3 changed files with 143 additions and 13 deletions

View file

@ -2206,6 +2206,36 @@ describe("SkillsScreen directory browsing (#2248)", () => {
);
});
it.each([
["no /SKILL.md suffix", "not-a-uri"],
// Ends with the suffix and so LOOKS addressable, but does not parse. The
// identity fallback returned the raw string here, producing the "root"
// `not a uri` and enabling a directory request built from a URI the
// conformance checks had already rejected (Copilot).
["unparseable but suffixed", "not a uri/SKILL.md"],
["relative, not a full URI", "demo/SKILL.md"],
])(
"renders no Directory section for a malformed skill URI (%s)",
async (_label, uri) => {
const user = userEvent.setup();
const odd: SkillEntry = {
uri,
frontmatter: { name: "odd", description: "d" },
resources: [],
};
renderWithMantine(
<ControlledSkillsScreen
skills={[odd]}
onReadResourceDirectory={directoryReader({})}
/>,
);
await user.click(screen.getByText("odd"));
expect(
screen.queryByRole("button", { name: /Directory/ }),
).not.toBeInTheDocument();
},
);
it("renders no Directory section for a skill whose URI is malformed", async () => {
// There is no root to browse, and `malformed-uri` already reports it in
// Conformance.

View file

@ -35,6 +35,7 @@ import {
skillDisplayName,
skillFileBytes,
skillEntriesMatch,
normalizeSkillUri,
skillUriIdentity,
SKILL_FILE_SUFFIX,
totalSkillBytes,
@ -1129,8 +1130,15 @@ export function SkillsScreen({
*/
const skillRoot = useMemo(() => {
if (!selected) return undefined;
const normalized = skillUriIdentity(selected.uri);
return normalized.endsWith(SKILL_FILE_SUFFIX)
// `normalizeSkillUri`, NOT `skillUriIdentity`: the latter falls back to the
// raw string when parsing fails, so `not a uri/SKILL.md` yielded the "root"
// `not a uri` and enabled the Directory section — letting the UI send a
// directory request derived from a URI the conformance checks had already
// rejected as malformed (Copilot). Identity is the right tool for
// COMPARING two spellings; it is the wrong one for deciding that a URI is
// well-formed enough to build a request from.
const normalized = normalizeSkillUri(selected.uri);
return normalized !== undefined && normalized.endsWith(SKILL_FILE_SUFFIX)
? normalized.slice(0, -SKILL_FILE_SUFFIX.length)
: undefined;
}, [selected]);

View file

@ -518,6 +518,71 @@ describe("verifySkills (#2248)", () => {
expect(report.files[0].status).toBe("verified");
});
it("bounds reads at the interoperability limit, not at the manifest length", async () => {
// The 512-entry limit is CHECKED but constrains nothing, so a hostile
// server advertising far more had the tool perform that many sequential
// reads after the report already knew the manifest was over (Copilot).
const skill: SkillEntry = {
uri: "skill://huge/SKILL.md",
frontmatter: { name: "huge", description: "Too many files" },
resources: Array.from({ length: 900 }, (_, i) => ({
uri: i === 0 ? "skill://huge/SKILL.md" : `skill://huge/f${i}.md`,
digest: `sha256:${"a".repeat(64)}`,
size: 1,
})),
};
const readResource = vi.fn(async (uri: string) => ({
result: { contents: [{ uri, text: "x" }] },
}));
const client = { readResource } as unknown as InspectorClientProtocol;
const [report] = await verifySkills(client, [skill]);
expect(readResource).toHaveBeenCalledTimes(512);
expect(report.files).toHaveLength(512);
// …and the overage is still REPORTED, so bounding the reads does not
// silence the finding that made them unnecessary.
expect(report.conformance).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "resource-limit-exceeded" }),
]),
);
});
it("still reads the entry's own file when the cap would exclude it", async () => {
// The frontmatter comparison is mandatory and must not be lost to a limit
// that exists to bound *other* files — so a self-entry pushed past the cap
// by a bloated manifest reaches the fallback read.
const skillMd = "---\nname: huge\ndescription: Served\n---\n\n# H\n";
const skill: SkillEntry = {
uri: "skill://huge/SKILL.md",
frontmatter: { name: "huge", description: "Listed" },
resources: [
...Array.from({ length: 600 }, (_, i) => ({
uri: `skill://huge/f${i}.md`,
digest: `sha256:${"a".repeat(64)}`,
size: 1,
})),
// Beyond the 512 cap.
{
uri: "skill://huge/SKILL.md",
digest: `sha256:${"a".repeat(64)}`,
size: 1,
},
],
};
const readResource = vi.fn(async (uri: string) => ({
result: {
contents: [{ uri, text: uri.endsWith("/SKILL.md") ? skillMd : "x" }],
},
}));
const client = { readResource } as unknown as InspectorClientProtocol;
const [report] = await verifySkills(client, [skill]);
expect(readResource).toHaveBeenCalledWith(
"skill://huge/SKILL.md",
undefined,
);
expect(report.frontmatter).toHaveLength(1);
});
it("reports every skill it was given, in order", async () => {
const a = await entry();
const b = await entry({ uri: "skill://demo/SKILL.md" });