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:
cliffhall 2026-09-07 21:09:26 -04:00
commit 8ae1a1664d
2 changed files with 217 additions and 47 deletions

View file

@ -117,7 +117,13 @@ function hasContent(yamlText: string): boolean {
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
* 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 —
* is not mistaken for a cycle.
*/
function jsonGraphError(
export function jsonGraphError(
value: unknown,
seen: Set<object>,
depth: number,
seen: Set<object> = new Set(),
depth = 0,
): string | undefined {
if (typeof value !== "object" || value === null) return undefined;
if (depth > MAX_FRONTMATTER_DEPTH) {

View file

@ -42,7 +42,11 @@ import {
type SkillResource,
} from "./skillsSchemas.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). */
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);
if ("error" in parsed) {
return [