fix: address Copilot review round 12 on #2248
**The read bounds could produce a false `ok: true`.** Entries past the cap are never fetched, `fileFailed` only examines fetched rows, and `resource-limit-exceeded` is a WARNING — so a manifest whose 513th file was tampered with reported the skill verified. That trades a denial of service for a wrong answer, which is the failure this whole PR argues against. A truncated read is now recorded as `incomplete` and forces `ok: false`, reported separately so a consumer can tell "this skill is wrong" from "this skill was not fully checked". And a self entry excluded by the cap is now verified against its declared digest, not merely read for frontmatter — otherwise the skill's own SKILL.md was the one file nobody checked. **`entryKey` could crash the TUI.** It called `JSON.stringify` on the whole server-controlled entry, during render. `skillEntryKey` is decided by the same `jsonGraphError` guard that bounds the frontmatter comparison, with a coarse fallback that still separates two unrepresentable entries by URI — collapsing them would show one skill's verdict under another's name. **The tab bar wraps and `tabsHeight` was hard-coded to 1.** An OAuth-capable stdio server serving Skills needs ~107 columns, so it wraps at 132 as well as at 80, and every pane below was sized a row too tall. `tabBarRows` derives it from the same list `Tabs` renders, via a shared `visibleTabs(flags)` — the duplicated filter being how the two would drift apart again. Nine tests in a new `tabsConfig.test.ts`, including the 80-column regression. Two assert properties rather than instances: rows never decrease as the terminal narrows, and a tab wider than the row gets its own rather than looping. 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
60d5f403e6
commit
27f1b2f1bd
2 changed files with 380 additions and 33 deletions
|
|
@ -9,6 +9,7 @@ import {
|
|||
checkSkillConformance,
|
||||
checkSkillFrontmatterMatch,
|
||||
checkSkillNameCollisions,
|
||||
skillEntryKey,
|
||||
getSkillsExtension,
|
||||
isSkillsExtensionSupported,
|
||||
normalizeSkillUri,
|
||||
|
|
@ -1201,3 +1202,49 @@ describe("checkSkillNameCollisions (#2248)", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillEntryKey (#2248)", () => {
|
||||
const base: SkillEntry = {
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter: { name: "demo", description: "A demo" },
|
||||
resources: [],
|
||||
};
|
||||
|
||||
it("distinguishes two entries that differ anywhere", () => {
|
||||
expect(skillEntryKey(base)).toBe(skillEntryKey({ ...base }));
|
||||
expect(skillEntryKey(base)).not.toBe(
|
||||
skillEntryKey({
|
||||
...base,
|
||||
frontmatter: { name: "demo", description: "changed" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("survives frontmatter too deep to serialize", () => {
|
||||
// `frontmatter` is unbounded server-controlled JSON, and this key is
|
||||
// computed during render — so `JSON.stringify` let one catalog entry crash
|
||||
// the pane that exists to report on it (Copilot).
|
||||
let deep: unknown = "leaf";
|
||||
for (let i = 0; i < 60000; i += 1) deep = { a: deep };
|
||||
const hostile = {
|
||||
...base,
|
||||
frontmatter: { name: "demo", deep },
|
||||
} as unknown as SkillEntry;
|
||||
expect(() => skillEntryKey(hostile)).not.toThrow();
|
||||
expect(skillEntryKey(hostile)).toContain("unrepresentable");
|
||||
});
|
||||
|
||||
it("still separates two unrepresentable entries by identity", () => {
|
||||
// The fallback is coarse, but it must not collapse distinct skills into
|
||||
// one key — that would show a verdict under the wrong name.
|
||||
let deep: unknown = "leaf";
|
||||
for (let i = 0; i < 60000; i += 1) deep = { a: deep };
|
||||
const a = { ...base, frontmatter: { deep } } as unknown as SkillEntry;
|
||||
const b = {
|
||||
...base,
|
||||
uri: "skill://other/SKILL.md",
|
||||
frontmatter: { deep },
|
||||
} as unknown as SkillEntry;
|
||||
expect(skillEntryKey(a)).not.toBe(skillEntryKey(b));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -538,6 +538,7 @@ describe("verifySkills (#2248)", () => {
|
|||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(readResource).toHaveBeenCalledTimes(512);
|
||||
expect(report.files).toHaveLength(512);
|
||||
expect(report.ok).toBe(false);
|
||||
// …and the overage is still REPORTED, so bounding the reads does not
|
||||
// silence the finding that made them unnecessary.
|
||||
expect(report.conformance).toEqual(
|
||||
|
|
@ -581,6 +582,9 @@ describe("verifySkills (#2248)", () => {
|
|||
const [report] = await verifySkills(client, [skill]);
|
||||
// Two fit exactly; the third would cross, so it is never requested.
|
||||
expect(readResource).toHaveBeenCalledTimes(2);
|
||||
// …and a verification that did not finish must not report success.
|
||||
expect(report.incomplete).toMatch(/2 of 3 manifest entries/);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.conformance).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "size-limit-exceeded" }),
|
||||
|
|
@ -588,6 +592,61 @@ describe("verifySkills (#2248)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not report success for a manifest it could not finish reading", async () => {
|
||||
// The trade this bound must NOT make: entries past the cap are never
|
||||
// fetched and `resource-limit-exceeded` is only a warning, so a manifest
|
||||
// whose 513th file is tampered with returned `ok: true` and the CLI said
|
||||
// the skill verified — a denial of service swapped for a false pass
|
||||
// (Copilot).
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://many/SKILL.md",
|
||||
frontmatter: { name: "many", description: "Over the entry limit" },
|
||||
resources: Array.from({ length: 600 }, (_, i) => ({
|
||||
uri: i === 0 ? "skill://many/SKILL.md" : `skill://many/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(report.incomplete).toBeDefined();
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies the entry's own file even when the cap excluded it", async () => {
|
||||
// The fallback exists for the frontmatter check, but reading the file and
|
||||
// then skipping the digest its manifest advertised would leave the skill's
|
||||
// own SKILL.md the one file nobody verified (Copilot).
|
||||
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,
|
||||
})),
|
||||
{
|
||||
uri: "skill://huge/SKILL.md",
|
||||
digest: `sha256:${"b".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]);
|
||||
const self = report.files.find((f) => f.uri === "skill://huge/SKILL.md");
|
||||
// A real verdict on the advertised digest, not merely a read.
|
||||
expect(self?.status).toBe("mismatch");
|
||||
expect(self?.expectedDigest).toBe(`sha256:${"b".repeat(64)}`);
|
||||
});
|
||||
|
||||
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
|
||||
|
|
@ -600,6 +659,9 @@ describe("verifySkills (#2248)", () => {
|
|||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(readResource).toHaveBeenCalledTimes(2);
|
||||
expect(report.files).toHaveLength(2);
|
||||
// Nothing was skipped, so nothing is reported as incomplete.
|
||||
expect(report.incomplete).toBeUndefined();
|
||||
expect(report.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("still reads the entry's own file when the cap would exclude it", async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue