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
2 changed files with 217 additions and 47 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
Loading…
Reference in a new issue