**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>
**A cyclic YAML alias crashed the tool.** `meta: &m [*m]` parses cleanly into
a self-referential array, and the frontmatter comparison is a recursive walk,
so a hostile server could take down `--verify` or the TUI with a stack
overflow instead of receiving a finding. Verified before fixing: it really
did raise `RangeError: Maximum call stack size exceeded`.
Rejected at the parse, so no consumer has to be cycle-safe on its own, and it
becomes an ordinary `frontmatter-unparsable` finding. Detection is per-PATH
rather than per-graph — `seen` unwinds on the way back up — so an alias reused
across siblings, which is ordinary YAML and represents fine in JSON, is not
mistaken for a cycle. A depth bound closes the same hole by its other door: a
legal, acyclic but absurdly nested document exhausts the stack just as well,
and a cycle check alone passes it.
**The web frontmatter check was gated on the presentation MIME.** It ran off
`previewParts`, which exists only when the displayed type is recognized as
markdown — so a `SKILL.md` a server typed `text/plain`, or served as a base64
blob, skipped a mandatory comparison while the pane still read as clean. It
now decodes the same fetched bytes the digest is taken over, which also stops
the two answers describing different derivations of the payload.
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>
Eight findings. Two were visible in a terminal capture I had already taken
and read past.
**A digest mismatch rendered with no digests.** `verifySkillResource` sets
`reason` for a SIZE mismatch — which short-circuits before hashing — while a
digest mismatch carries `expectedDigest` / `actualDigest` and no `reason`, so
the TUI printed a bare `✗ notes.md` with nothing to act on. `failureDetail`
now falls back to the expected/actual pair, truncated to fit the pane.
**A dynamic skill's read failure rendered nowhere.** Its synthetic
`read-error` row lives in the report, not the manifest, and the manifest is
empty for such a skill by definition — so the pane said only "Verification
FAILED". There is a Read failures block for any report file the manifest does
not cover, matched on normalized identity.
**The non-finite sentinel could be aliased.** A listing whose value genuinely
was `{"#non-finite":"NaN"}` canonicalized identically to a served `.nan`, so
the round-3 fix moved the bug rather than closing it. Any encoding into the
value space can be aliased by a document containing the encoding, so the
comparison is structural now (`jsonLikeEqual`) and there is no sentinel at
all; `Object.is` gives NaN === NaN while keeping ±Infinity distinct.
Serialization is used only for the message.
**`parseSkillFrontmatter("null")` returned `{ fields: {} }`,** contradicting
its own non-mapping contract: an explicit null scalar parses to exactly what
an empty block does. They are told apart by the source, since the parsed
value cannot.
Also: the `files` doc comment still claimed it is empty for a dynamic skill,
which the round-2 fix made untrue; and three inline Mantine elements with two
static styling props each are extracted to `.withProps()` constants, per the
convention the rest of the file follows.
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>
Closes#2248.
Completes SEP-2640 support across all three clients, and closes the one
obligation #2234 deliberately left open.
- `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML
frontmatter against the entry the listing advertised, field by field. No
digest can cover this: a digest is taken over the bytes the server served,
so it proves the file was not altered in transit and says nothing about
whether the listing described it honestly. Needs a real YAML parser; `yaml`
was already a root dependency, so this adds no package — but it does newly
put it on core/'s import graph, hence the three bundler `external` lists.
- `resources/directory/read`: result schemas defined against the normative
text, `InspectorClient.readResourceDirectory`, a Directory section on the
Skills screen, and `--method resources/directory/read` in the CLI. The
client refuses the call locally when the server did not declare
`directoryRead`, which is the SEP's MUST NOT. A child the directory lists
but the entry does not is marked `not listed` rather than merged into the
manifest — the SEP calls a directory result a live observation and forbids
treating it as extending the manifest.
- CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus
`--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a
violation. Reads are sequential; a conforming manifest may declare 512
entries.
- TUI: a Skills pane, shown only when the server declares the extension. Each
row carries its conformance verdict as a glyph as well as a colour, since
the pane is read over ssh and through script(1). Enter verifies.
- `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording
it per file: it says the session's authorization expired, so absorbing it
would report N identical read failures and swallow the error the TUI and
the web commands key off to reauthorize.
Two open questions settled, both in code comments where they will be found:
`skills/get` carries no caching attributes because SEP-2640 leaves the
question open in as many words, so requiring them would fail a conforming
server; and there is no `PagedSkillsState` because every consumer of this
list is a whole-catalog verdict — computed over page one of three, "this
server's skills conform" is not merely partial but wrong.
The fixture grows two skills, both for checks that were otherwise
undemonstrable: `lying-listing` (listing and file disagree, digest still
verifies) and `stale-manifest` (serves a file its manifest does not declare).
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>