fix: address Copilot review round 8 on #2248
**The depth guard covered only the YAML side.** The listing arrives over JSON-RPC so it cannot be cyclic, but it is just as unbounded in depth — and both `jsonLikeEqual` and its message formatter walk it, so a server advertising a deeply nested value crashed the tool exactly as a cyclic served one did. Reproduced first: 60,000 levels against a shallow served value gives `RangeError: Maximum call stack size exceeded`. `jsonGraphError` is shared now and runs over `entry.frontmatter` before the file is parsed — there is no point reading a SKILL.md if the thing to compare it against is unusable. **A frontmatter mismatch stayed behind a collapsed section.** Round 7 made the badge count these findings; the alerts explaining a mandatory verification failure were still one click away on a section that opens collapsed for a structurally clean entry. Conformance now reveals itself when they appear, keyed on the entry so it fires once rather than fighting a user who collapses it again. That reveal exposed three fixtures serving one skill's SKILL.md for every URI — a genuine frontmatter mismatch the check was right to report. The stale- batch invariant test and the Storybook fixtures now derive each file from the frontmatter its own entry advertises, with digests computed from those bytes rather than hard-coded, so the class of drift is closed rather than the instances patched. `SAMPLE_FM` is the single source for both halves. One story subtlety worth recording: only a skill's OWN SKILL.md is derived. Serving it for `notes.md` too made the tampered fixture fail its SIZE check first, which is a different finding from the digest mismatch that story exists to demonstrate. 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
d6b1e36e7e
commit
8ae1a1664d
6 changed files with 217 additions and 47 deletions
|
|
@ -3,6 +3,7 @@ import type { ComponentProps } from "react";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||||
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
|
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
|
||||||
|
import { sha256Bytes } from "@inspector/core/mcp/sha256";
|
||||||
import { SkillsScreen } from "./SkillsScreen";
|
import { SkillsScreen } from "./SkillsScreen";
|
||||||
import type { SkillsUiState } from "./SkillsScreen";
|
import type { SkillsUiState } from "./SkillsScreen";
|
||||||
import { EMPTY_SKILLS_UI } from "../screenUiState";
|
import { EMPTY_SKILLS_UI } from "../screenUiState";
|
||||||
|
|
@ -16,29 +17,74 @@ function StatefulSkillsScreen(args: ComponentProps<typeof SkillsScreen>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const REF_TEXT = "# Column rules\n";
|
const REF_TEXT = "# Column rules\n";
|
||||||
const SELF_TEXT = "# skill\n";
|
|
||||||
// The real digests of those two strings, so the clean skill actually verifies
|
/**
|
||||||
// when the "Verify all" story runs — a placeholder would demo a false green.
|
* A skill's own `SKILL.md`, built FROM the frontmatter its entry advertises.
|
||||||
const REF_DIGEST =
|
*
|
||||||
"sha256:e201429aa2684958ca1a0537ab4eb4b7eb3a81c71e7cc7a11397eb500738e015";
|
* SEP-2640 requires the two to match field for field, and the screen now checks
|
||||||
const SELF_DIGEST =
|
* it — so a shared placeholder body with no frontmatter made every "conforming"
|
||||||
"sha256:6504f2de0a1febf7492c3b98f93d9ab49558eb364607a706f02fe9a75aa7f75b";
|
* story report a `frontmatter-absent` error. Deriving the file makes that class
|
||||||
|
* of drift impossible rather than merely fixed, which is the same discipline
|
||||||
|
* `test-servers/src/skills.ts` and the unit fixtures apply.
|
||||||
|
*/
|
||||||
|
const skillMd = (name: string, description: string) =>
|
||||||
|
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`;
|
||||||
|
|
||||||
|
/** Real digests, computed from the very bytes served, so the clean skill
|
||||||
|
* actually verifies when the "Verify all" story runs — a hard-coded
|
||||||
|
* placeholder would demo a false green, and could not survive an edit to the
|
||||||
|
* text above. `sha256Bytes` is the repo's synchronous implementation, which is
|
||||||
|
* what lets this happen at module scope in a CSF file. */
|
||||||
|
const digestOf = (text: string) =>
|
||||||
|
`sha256:${[...sha256Bytes(new TextEncoder().encode(text))]
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")}`;
|
||||||
|
|
||||||
|
const REF_DIGEST = digestOf(REF_TEXT);
|
||||||
|
|
||||||
|
/** The frontmatter each sample skill advertises, keyed by its path segment. */
|
||||||
|
const SAMPLE_FM: Record<string, { name: string; description: string }> = {
|
||||||
|
"data-analysis": {
|
||||||
|
name: "data-analysis",
|
||||||
|
description: "Analyze a CSV and summarize its columns",
|
||||||
|
},
|
||||||
|
"tampered-notes": {
|
||||||
|
name: "tampered-notes",
|
||||||
|
description: "Advertises a digest its bytes do not match",
|
||||||
|
},
|
||||||
|
"wrong-folder": {
|
||||||
|
name: "right-name",
|
||||||
|
description: "Served from a folder that disagrees with its name",
|
||||||
|
},
|
||||||
|
"big-manifest": {
|
||||||
|
name: "big-manifest",
|
||||||
|
description: "A skill with a long manifest",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The file a given skill path serves, derived from its own frontmatter. */
|
||||||
|
const selfText = (path: string) => {
|
||||||
|
const fm = SAMPLE_FM[path];
|
||||||
|
return fm ? skillMd(fm.name, fm.description) : "# skill\n";
|
||||||
|
};
|
||||||
|
|
||||||
/** Every manifest lists the skill's own SKILL.md — a manifest is the complete
|
/** Every manifest lists the skill's own SKILL.md — a manifest is the complete
|
||||||
* file set, so one that omits it is a `manifest-missing-self` error. */
|
* file set, so one that omits it is a `manifest-missing-self` error. */
|
||||||
const selfEntry = (path: string) => ({
|
const selfEntry = (path: string) => {
|
||||||
uri: `skill://${path}/SKILL.md`,
|
const text = selfText(path);
|
||||||
digest: SELF_DIGEST,
|
return {
|
||||||
size: 8,
|
uri: `skill://${path}/SKILL.md`,
|
||||||
});
|
digest: digestOf(text),
|
||||||
|
size: new TextEncoder().encode(text).byteLength,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const sampleSkills: SkillEntry[] = [
|
const sampleSkills: SkillEntry[] = [
|
||||||
{
|
{
|
||||||
uri: "skill://data-analysis/SKILL.md",
|
uri: "skill://data-analysis/SKILL.md",
|
||||||
frontmatter: {
|
// Read from SAMPLE_FM, which is also what the served file is built from —
|
||||||
name: "data-analysis",
|
// so the entry and its SKILL.md agree by construction.
|
||||||
description: "Analyze a CSV and summarize its columns",
|
frontmatter: SAMPLE_FM["data-analysis"],
|
||||||
},
|
|
||||||
resources: [
|
resources: [
|
||||||
selfEntry("data-analysis"),
|
selfEntry("data-analysis"),
|
||||||
{
|
{
|
||||||
|
|
@ -50,10 +96,7 @@ const sampleSkills: SkillEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uri: "skill://tampered-notes/SKILL.md",
|
uri: "skill://tampered-notes/SKILL.md",
|
||||||
frontmatter: {
|
frontmatter: SAMPLE_FM["tampered-notes"],
|
||||||
name: "tampered-notes",
|
|
||||||
description: "Advertises a digest its bytes do not match",
|
|
||||||
},
|
|
||||||
resources: [
|
resources: [
|
||||||
selfEntry("tampered-notes"),
|
selfEntry("tampered-notes"),
|
||||||
{
|
{
|
||||||
|
|
@ -76,10 +119,7 @@ const sampleSkills: SkillEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uri: "skill://wrong-folder/SKILL.md",
|
uri: "skill://wrong-folder/SKILL.md",
|
||||||
frontmatter: {
|
frontmatter: SAMPLE_FM["wrong-folder"],
|
||||||
name: "right-name",
|
|
||||||
description: "URI path segment disagrees with frontmatter.name",
|
|
||||||
},
|
|
||||||
resources: [selfEntry("wrong-folder")],
|
resources: [selfEntry("wrong-folder")],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -94,11 +134,19 @@ const meta: Meta<typeof SkillsScreen> = {
|
||||||
ui: EMPTY_SKILLS_UI,
|
ui: EMPTY_SKILLS_UI,
|
||||||
onUiChange: fn(),
|
onUiChange: fn(),
|
||||||
onRefreshList: fn(),
|
onRefreshList: fn(),
|
||||||
onReadSkillFile: fn(async (uri: string) =>
|
onReadSkillFile: fn(async (uri: string) => {
|
||||||
uri.endsWith("reference.md")
|
if (uri.endsWith("reference.md")) return { text: REF_TEXT };
|
||||||
? { text: REF_TEXT }
|
// Only a skill's OWN SKILL.md is derived from its frontmatter. Every
|
||||||
: { text: SELF_TEXT, mimeType: "text/markdown" },
|
// other manifest file keeps the 8-byte placeholder its entry declares —
|
||||||
),
|
// serving the SKILL.md for `notes.md` made the tampered fixture fail its
|
||||||
|
// SIZE check first, which is a different finding from the digest
|
||||||
|
// mismatch that story exists to show.
|
||||||
|
if (!uri.endsWith("/SKILL.md")) {
|
||||||
|
return { text: "# skill\n", mimeType: "text/markdown" };
|
||||||
|
}
|
||||||
|
const path = uri.slice("skill://".length, -"/SKILL.md".length);
|
||||||
|
return { text: selfText(path), mimeType: "text/markdown" };
|
||||||
|
}),
|
||||||
// Echoes back the entry `skills/list` advertised, so "Fetch with
|
// Echoes back the entry `skills/list` advertised, so "Fetch with
|
||||||
// skills/get" demonstrates the matching case rather than throwing.
|
// skills/get" demonstrates the matching case rather than throwing.
|
||||||
onGetSkill: fn(async (uri: string) => {
|
onGetSkill: fn(async (uri: string) => {
|
||||||
|
|
@ -190,10 +238,7 @@ const LONG_SKILL_MD = [
|
||||||
// overflowing content.
|
// overflowing content.
|
||||||
const manyFilesSkill: SkillEntry = {
|
const manyFilesSkill: SkillEntry = {
|
||||||
uri: "skill://big-manifest/SKILL.md",
|
uri: "skill://big-manifest/SKILL.md",
|
||||||
frontmatter: {
|
frontmatter: SAMPLE_FM["big-manifest"],
|
||||||
name: "big-manifest",
|
|
||||||
description: "A conforming skill that declares a great many files",
|
|
||||||
},
|
|
||||||
resources: [
|
resources: [
|
||||||
selfEntry("big-manifest"),
|
selfEntry("big-manifest"),
|
||||||
...Array.from({ length: 120 }, (_, i) => ({
|
...Array.from({ length: 120 }, (_, i) => ({
|
||||||
|
|
@ -217,8 +262,10 @@ const manyFilesSkill: SkillEntry = {
|
||||||
export const LongManifest: Story = {
|
export const LongManifest: Story = {
|
||||||
args: {
|
args: {
|
||||||
skills: [manyFilesSkill],
|
skills: [manyFilesSkill],
|
||||||
|
// Derived like every other fixture, so the frontmatter check stays silent
|
||||||
|
// and this story measures only the long-manifest layout it is about.
|
||||||
onReadSkillFile: fn(async () => ({
|
onReadSkillFile: fn(async () => ({
|
||||||
text: "---\nname: big-manifest\n---\n\n# Big manifest\n",
|
text: selfText("big-manifest"),
|
||||||
mimeType: "text/markdown",
|
mimeType: "text/markdown",
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|
@ -276,10 +323,12 @@ const hostileHeaderSkill: SkillEntry = {
|
||||||
resources: [
|
resources: [
|
||||||
{
|
{
|
||||||
uri: `skill://${"very-long-path-segment/".repeat(30)}SKILL.md`,
|
uri: `skill://${"very-long-path-segment/".repeat(30)}SKILL.md`,
|
||||||
digest: SELF_DIGEST,
|
// This fixture exercises LAYOUT under hostile strings; its digests are
|
||||||
|
// never verified by the story, so a placeholder is honest here.
|
||||||
|
digest: `sha256:${"0".repeat(64)}`,
|
||||||
size: 8,
|
size: 8,
|
||||||
},
|
},
|
||||||
{ uri: HOSTILE_FILE_URI, digest: SELF_DIGEST, size: 8 },
|
{ uri: HOSTILE_FILE_URI, digest: `sha256:${"0".repeat(64)}`, size: 8 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,9 @@ const ALL_SKILLS = [
|
||||||
/** Everything `readFixtureFile` can serve a `SKILL.md` for. */
|
/** Everything `readFixtureFile` can serve a `SKILL.md` for. */
|
||||||
const SERVED_SKILLS = [...ALL_SKILLS, ACME, GLOBEX];
|
const SERVED_SKILLS = [...ALL_SKILLS, ACME, GLOBEX];
|
||||||
|
|
||||||
|
/** The many-row fixture's frontmatter, shared with the stub that serves it. */
|
||||||
|
const MANY_FM: Frontmatter = { name: "many", description: "Many rows" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `resources/read` that serves the fixture bytes for any known URI. A skill's
|
* A `resources/read` that serves the fixture bytes for any known URI. A skill's
|
||||||
* own `SKILL.md` comes from {@link skillMdFor}, so it agrees with the entry's
|
* own `SKILL.md` comes from {@link skillMdFor}, so it agrees with the entry's
|
||||||
|
|
@ -386,17 +389,22 @@ describe("SkillsScreen", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
// Held open so the batch is still in flight when the selection changes.
|
// Held open so the batch is still in flight when the selection changes.
|
||||||
const releases: (() => void)[] = [];
|
const releases: (() => void)[] = [];
|
||||||
|
// Each URI gets the file its OWN entry implies, so the frontmatter check
|
||||||
|
// stays silent and this test measures only the open-state invariant it is
|
||||||
|
// about. A stub serving one skill's text for every URI produces a genuine
|
||||||
|
// mismatch, which now reveals Conformance by design.
|
||||||
const onReadSkillFile = vi.fn(
|
const onReadSkillFile = vi.fn(
|
||||||
() =>
|
(uri: string) =>
|
||||||
new Promise<{ text: string }>((resolve) => {
|
new Promise<{ text: string }>((resolve) => {
|
||||||
releases.push(() => resolve({ text: SELF_TEXT }));
|
const fm = uri.startsWith("skill://many/") ? MANY_FM : CLEAN_FM;
|
||||||
|
releases.push(() => resolve({ text: skillMdFor(fm) }));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// More rows than the concurrency cap, so workers keep pulling.
|
// More rows than the concurrency cap, so workers keep pulling.
|
||||||
const manyRows: SkillEntry = {
|
const manyRows: SkillEntry = {
|
||||||
...CLEAN_SKILL,
|
...CLEAN_SKILL,
|
||||||
uri: "skill://many/SKILL.md",
|
uri: "skill://many/SKILL.md",
|
||||||
frontmatter: { name: "many", description: "Many rows" },
|
frontmatter: MANY_FM,
|
||||||
resources: Array.from({ length: 10 }, (_, i) => ({
|
resources: Array.from({ length: 10 }, (_, i) => ({
|
||||||
uri: i === 0 ? "skill://many/SKILL.md" : `skill://many/f${i}.md`,
|
uri: i === 0 ? "skill://many/SKILL.md" : `skill://many/f${i}.md`,
|
||||||
digest: SELF_DIGEST,
|
digest: SELF_DIGEST,
|
||||||
|
|
@ -2315,7 +2323,7 @@ describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||||
};
|
};
|
||||||
renderWithMantine(<ControlledSkillsScreen skills={[lying]} />);
|
renderWithMantine(<ControlledSkillsScreen skills={[lying]} />);
|
||||||
await user.click(screen.getByText("data-analysis"));
|
await user.click(screen.getByText("data-analysis"));
|
||||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
// No click to expand: a frontmatter finding reveals the section itself.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getByTestId("skill-frontmatter-issues"),
|
screen.getByTestId("skill-frontmatter-issues"),
|
||||||
|
|
@ -2352,7 +2360,7 @@ describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByText("data-analysis"));
|
await user.click(screen.getByText("data-analysis"));
|
||||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
// No click to expand: a frontmatter finding reveals the section itself.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getByTestId("skill-frontmatter-issues"),
|
screen.getByTestId("skill-frontmatter-issues"),
|
||||||
|
|
@ -2377,7 +2385,7 @@ describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByText("data-analysis"));
|
await user.click(screen.getByText("data-analysis"));
|
||||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
// No click to expand: a frontmatter finding reveals the section itself.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getByTestId("skill-frontmatter-issues"),
|
screen.getByTestId("skill-frontmatter-issues"),
|
||||||
|
|
@ -2385,6 +2393,41 @@ describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reveals Conformance when a frontmatter finding arrives", async () => {
|
||||||
|
// A structurally clean entry opens collapsed, and the frontmatter findings
|
||||||
|
// arrive later from the SKILL.md read — so the alerts explaining a
|
||||||
|
// mandatory verification failure sat behind a click the reader had no
|
||||||
|
// reason to make (Copilot).
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const lying: SkillEntry = {
|
||||||
|
...CLEAN_SKILL,
|
||||||
|
frontmatter: { name: "data-analysis", description: "Disagrees" },
|
||||||
|
};
|
||||||
|
renderWithMantine(<ControlledSkillsScreen skills={[lying]} />);
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Conformance/ }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true"),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("skill-frontmatter-issues")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a clean entry's Conformance collapsed", async () => {
|
||||||
|
// The reveal must not fire when there is nothing to reveal, or it undoes
|
||||||
|
// the auto-collapse it sits next to.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<ControlledSkillsScreen />);
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(readFixtureFile).toHaveBeenCalledWith(CLEAN_SKILL.uri),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("counts frontmatter findings in the Conformance badge", async () => {
|
it("counts frontmatter findings in the Conformance badge", async () => {
|
||||||
// The findings render inside this section, so counting only the static
|
// The findings render inside this section, so counting only the static
|
||||||
// listing issues left the badge saying `0 error(s)` above a red
|
// listing issues left the badge saying `0 error(s)` above a red
|
||||||
|
|
|
||||||
|
|
@ -1482,6 +1482,27 @@ export function SkillsScreen({
|
||||||
* entry whose only finding is a banner one ended up showing an empty findings
|
* entry whose only finding is a banner one ended up showing an empty findings
|
||||||
* container instead of "no structural issues".
|
* container instead of "no structural issues".
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Reveal Conformance when the frontmatter check finds something.
|
||||||
|
*
|
||||||
|
* A structurally clean entry opens with the section COLLAPSED — the header
|
||||||
|
* badge carries the whole answer — but the frontmatter findings arrive later,
|
||||||
|
* from the `SKILL.md` read, and land inside that collapsed section. The badge
|
||||||
|
* now counts them, so the number changes; the alerts explaining a mandatory
|
||||||
|
* verification failure were still a click away (Copilot).
|
||||||
|
*
|
||||||
|
* Keyed on the entry so it fires **once** per skill, when findings first
|
||||||
|
* appear, rather than fighting a user who deliberately collapses it again.
|
||||||
|
* `useValueChange` runs during render and does only `setState`, as that hook
|
||||||
|
* requires; the key is a primitive so `Object.is` cannot loop.
|
||||||
|
*/
|
||||||
|
useValueChange(frontmatterIssues.length > 0 ? manifestKey : "", (next) => {
|
||||||
|
if (next === "") return;
|
||||||
|
setOpenSections((prev) =>
|
||||||
|
prev.includes("conformance") ? prev : [...prev, "conformance"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const listedIssues = useMemo(
|
const listedIssues = useMemo(
|
||||||
() =>
|
() =>
|
||||||
issues.filter(
|
issues.filter(
|
||||||
|
|
|
||||||
|
|
@ -1035,6 +1035,36 @@ describe("checkSkillFrontmatterMatch (#2248)", () => {
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bounds the LISTING side too, not only the served YAML", () => {
|
||||||
|
// The listing arrives over JSON-RPC so it cannot be cyclic, but it is just
|
||||||
|
// as unbounded in depth — and both the comparison and its message
|
||||||
|
// formatter walk it, so an absurdly nested advertised value crashed the
|
||||||
|
// tool exactly as a cyclic served one did (Copilot).
|
||||||
|
let deep: unknown = "leaf";
|
||||||
|
for (let i = 0; i < 5000; i += 1) deep = { a: deep };
|
||||||
|
const issues = checkSkillFrontmatterMatch(
|
||||||
|
entry({ x: deep as Record<string, unknown> }),
|
||||||
|
file("x: shallow"),
|
||||||
|
);
|
||||||
|
expect(issues).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "frontmatter-unparsable",
|
||||||
|
severity: "error",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(issues[0].message).toMatch(/listing's own frontmatter/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still compares an ordinarily nested listing value", () => {
|
||||||
|
// The bound must not reject anything a real skill would carry.
|
||||||
|
expect(
|
||||||
|
checkSkillFrontmatterMatch(
|
||||||
|
entry({ meta: { a: { b: { c: [1, 2] } } } }),
|
||||||
|
file("meta:\n a:\n b:\n c: [1, 2]"),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports nothing for two empty frontmatters", () => {
|
it("reports nothing for two empty frontmatters", () => {
|
||||||
expect(checkSkillFrontmatterMatch(entry({}), file(""))).toEqual([]);
|
expect(checkSkillFrontmatterMatch(entry({}), file(""))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,13 @@ function hasContent(yamlText: string): boolean {
|
||||||
const MAX_FRONTMATTER_DEPTH = 64;
|
const MAX_FRONTMATTER_DEPTH = 64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why a parsed frontmatter cannot be compared, or `undefined` when it can.
|
* Why a frontmatter value cannot be compared, or `undefined` when it can.
|
||||||
|
*
|
||||||
|
* Exported because **both sides need it**. The served side can be cyclic; the
|
||||||
|
* listed side arrives over JSON-RPC and cannot be, but it is just as unbounded
|
||||||
|
* in DEPTH — a server can advertise a listing nested tens of thousands of
|
||||||
|
* levels deep, and the comparison and its message formatter both recurse. A
|
||||||
|
* guard on only the YAML side left that door open (Copilot).
|
||||||
*
|
*
|
||||||
* ⚠️ **A YAML document is a graph, not a tree, and JSON is a tree.** An alias
|
* ⚠️ **A YAML document is a graph, not a tree, and JSON is a tree.** An alias
|
||||||
* can refer to its own ancestor — `meta: &m [*m]` parses without error into a
|
* can refer to its own ancestor — `meta: &m [*m]` parses without error into a
|
||||||
|
|
@ -136,10 +142,10 @@ const MAX_FRONTMATTER_DEPTH = 64;
|
||||||
* — which YAML aliases make ordinary and which JSON represents perfectly well —
|
* — which YAML aliases make ordinary and which JSON represents perfectly well —
|
||||||
* is not mistaken for a cycle.
|
* is not mistaken for a cycle.
|
||||||
*/
|
*/
|
||||||
function jsonGraphError(
|
export function jsonGraphError(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
seen: Set<object>,
|
seen: Set<object> = new Set(),
|
||||||
depth: number,
|
depth = 0,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (typeof value !== "object" || value === null) return undefined;
|
if (typeof value !== "object" || value === null) return undefined;
|
||||||
if (depth > MAX_FRONTMATTER_DEPTH) {
|
if (depth > MAX_FRONTMATTER_DEPTH) {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ import {
|
||||||
type SkillResource,
|
type SkillResource,
|
||||||
} from "./skillsSchemas.js";
|
} from "./skillsSchemas.js";
|
||||||
import { sha256Bytes } from "./sha256.js";
|
import { sha256Bytes } from "./sha256.js";
|
||||||
import { parseSkillFrontmatter, splitSkillFile } from "./skillFile.js";
|
import {
|
||||||
|
jsonGraphError,
|
||||||
|
parseSkillFrontmatter,
|
||||||
|
splitSkillFile,
|
||||||
|
} from "./skillFile.js";
|
||||||
|
|
||||||
/** Maximum resource entries a single skill may declare (SEP-2640). */
|
/** Maximum resource entries a single skill may declare (SEP-2640). */
|
||||||
export const SKILL_MAX_RESOURCE_ENTRIES = 512;
|
export const SKILL_MAX_RESOURCE_ENTRIES = 512;
|
||||||
|
|
@ -852,6 +856,23 @@ export function checkSkillFrontmatterMatch(
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// The LISTING side is bounded too, before anything recurses over it. It
|
||||||
|
// arrives over JSON-RPC so it cannot be cyclic, but it is just as unbounded
|
||||||
|
// in depth — and both `jsonLikeEqual` and `displayValue` walk it, so a
|
||||||
|
// server advertising an absurdly nested value crashed the tool exactly as a
|
||||||
|
// cyclic served one did (Copilot). Checked before the file is parsed: there
|
||||||
|
// is no point reading one if the thing to compare it against is unusable.
|
||||||
|
const listedError = jsonGraphError(entry.frontmatter);
|
||||||
|
if (listedError) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: "frontmatter-unparsable",
|
||||||
|
severity: "error",
|
||||||
|
message: `The listing's own frontmatter cannot be compared: ${listedError}`,
|
||||||
|
resourceUri: entry.uri,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
const parsed = parseSkillFrontmatter(frontmatter);
|
const parsed = parseSkillFrontmatter(frontmatter);
|
||||||
if ("error" in parsed) {
|
if ("error" in parsed) {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue