fix(ae1): keep reference-resolution caveats from flagging SKILL.md self-references - #661
Conversation
A path in SKILL.md that is not in the bundle (e.g. `templates/config.yaml`) records a reference_missing ledger event on SKILL.md. AE1 then treated SKILL.md as partially inspected, so every backticked `SKILL.md` in the same file became a HIGH analysis-evasion finding. SkillEvaluator blocks Tier 1 on any HIGH finding, so ordinary skills failed. A missing reference names a path the bundle does not carry; it leaves no bytes of the mentioning file uninspected. Ignore reference_missing events when judging whether a resolved reference target was completely inspected. The completeness ledger, CAUTION verdict and MCP gate still report the missing path. Content limitations on SKILL.md, such as static_parse_limit, still produce AE1, and its evidence no longer lists the missing reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @rng1995, thank you for this fix. It unblocks a real customer workflow, and the tests and before/after evidence made it easy to check. I found no blocking issues; the comments below are follow-ups.
What the change does. When a resolved reference points at SKILL.md, _reference_coverage_findings now ignores reference_missing ledger events for that target. It does this before deciding the disposition, building the AE1 evidence and running the format-only check. The missing path still shows up as a completeness caveat.
How I checked it
- Traced every producer and consumer of these events.
- Ran the 3 new tests on
origin/mainin a scratch worktree: all 3 fail. On this branch they pass. - Scanned 9 fixtures on
mainand the branch through JSON, SARIF, markdown, the CLI--fail-on-*flags and MCPrun_scan.
Verified OK
- Only one producer.
reference_missingis emitted in one place (build_context.py~2826-2843), always withpath=primary_path. Other scan modes don't reach this code:- Nested archives don't resolve references.
- Recursive scans run a separate graph per skill.
- Transitive results are merged after the graph runs.
So the filter can only affect references whose target is the primary file.
- SKILL.md inventory disposition stays
analyzedwhen references are missing. Onlyreference_extraction_limit/source_partialset it topartial, so the fix is complete. Leavingreference_extraction_limitalone is correct, because the inventory still triggers AE1 for it. - Other checks still read unfiltered data.
fatal_paths,invalid_status_paths,duplicate_inventory_pathsandledger_evidence_completeall use the unfiltered ledger or state._has_only_format_limitationsneeds a verified passive PNG, so it can't be affected. - Real limitations still produce AE1. With a real
static_parse_limiton SKILL.md, AE1 is still HIGH and its evidence now lists onlystatic_parse_limit. - Odd
reason_codevalues keep AE1. None, an absent key or a list all still produce it. - The caveat is still reported everywhere:
- JSON
ledger_exceptions - SARIF
toolExecutionNotifications - the markdown table
is_complete=false/CAUTION--fail-on-incompletestill exits 1--fail-on-findingsnow exits 0 (it was 1 on main)- MCP
safe_to_installis unchanged
- JSON
- Lint and tests pass.
ruff checkandruff format --checkpass. The touched test files pass (558), as do the integration tests intest_opaque_reference_reporting.py(47).
Non-blocking follow-ups (details inline)
- Ambiguous references (
reference_unresolved) still turn aSKILL.mdself-reference into a false AE1 HIGH. Either filter them too, or pin the current behavior with a test and explain why. - Filter once, when building
events_by_path, instead of for every reference. - Match the exact event shape build_context emits, so a future producer can't erase AE1.
- The test helper's type annotation doesn't match what
ledger_eventreturns. - Parametrize the end-to-end test over the other self-reference forms.
PR description. Two small fixes:
- "Only
SKILL.md(the primary file) can carry these" should say the primary file can be lowercaseskill.md(build_context.py:2708-2710). The fix already covers that case; I checked it with a scan. - "Ambiguous references … deliberately left unchanged" gives no reason; see follow-up 1.
Decision: Comment (no blocking issues). Reviewed head 8d1c5a97ca6d4153fb951267854db463df0a9b21. CI is green: changes, lint, test-unit, OpenCode TypeScript Tests, DCO Check, docker-smoke.
Reference resolution records an ambiguous mention (reference_unresolved) on SKILL.md the same way it records a missing one. It is about which bundled file a mention means, not SKILL.md bytes left uninspected, so a SKILL.md self-reference no longer turns it into AE1 either. The caveat still reaches the completeness ledger, and MCP safe_to_install stays False for it. Skip only the exact event shape build_context emits (partial outcome, reference_resolution phase, one of the two reasons), once while indexing events by path. Any other shape still counts toward AE1, as does reference_extraction_limit. Parametrize the end-to-end test over markdown-link, lowercase skill.md and ambiguous variants, pin the out-of-shape events in unit tests, and type the test helper with InspectionLedgerEvent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Summary
A skill author reported that SkillEvaluator's Tier 1 check (SkillSpector static scan) blocks ordinary skills with a HIGH
AE1"Referenced artifact was not completely inspected" finding onSKILL.mditself.Two things in
SKILL.mdcombine to cause it:`templates/config.yaml`". Reference resolution records areference_missingledger event, and records it againstSKILL.md, the file that mentions the path. An ambiguous mention (one that matches more than one bundled file) is recorded the same way, asreference_unresolved.`SKILL.md`. For example, "Keep`SKILL.md`under 500 lines." This resolves as a reference toSKILL.mditself. A markdown link such as[this file](SKILL.md)does the same._reference_coverage_findingsthen sees apartialevent on the targetSKILL.mdand emits a HIGHAE1for every such mention. SkillEvaluator blocks on any HIGH finding.A missing reference names a path the bundle does not carry, and an ambiguous one leaves open which bundled file a mention means. Neither leaves bytes of the mentioning file uninspected. The NVCARPS-154 acceptance tests already expect each half separately:
Only the combination produced AE1.
Change
src/skillspector/nodes/finalize_inspection_ledger.py: when indexing ledger events by path for AE1, skip reference-resolution caveats. The skip matches exactly the event shapebuild_contextemits:outcome=partial,phase="reference_resolution", andreason_codeofreference_missingorreference_unresolved. These events are only recorded on the primary file, which isSKILL.mdor, when that is the only primary, lowercaseskill.md. So in practice this affects only references that resolve to the primary file.The filter runs once, while building the per-path index; that index is only used to find a reference target's events. The other checks (fatal paths, status evidence, truncation, canonicalization) still read the unfiltered ledger.
What stays the same:
reference_missing/reference_unresolvedledger exception.is_completestaysfalse, the recommendation staysCAUTION, and--fail-on-incompletestill exits 1.safe_to_installhandling is unchanged. Missing-reference-only caveats already pass. Ambiguous references still returnsafe_to_install=false, because that gate depends only on the ledger exception (mcp_server.py), not on AE1. That is where the fail-closed signal for ambiguity comes from.SKILL.md, such asstatic_parse_limit, still produce AE1 for a self-reference. Its evidence now lists only the real limitation.failedoutcome, a different phase, orreference_extraction_limit(which leaves part ofSKILL.mdunexamined).Before and after
skillspector scan <skill> --no-llm --format json, withSKILL.mdcontaining`templates/config.yaml`(missing) and`SKILL.md`:main@ 8831219AE1HIGH,SKILL.md (partial)SKILL.md: reference_missingSKILL.md: reference_missingThe ambiguous case (
`config.yaml`with botha/config.yamlandb/config.yamlpresent, plus`SKILL.md`) behaves the same way: no AE1 on this branch,SKILL.md: reference_unresolvedin the ledger exceptions,CAUTION, and MCPrun_scanstill returnssafe_to_install=false.The markdown-link self-reference, the markdown-link missing target and the lowercase
skill.mdprimary also produce no AE1, with the caveat kept.Controls:
SAFE.static_parse_limit(the quoted$(...)cases tracked in static_patterns_tool_misuse: common bash idioms triggerstatic_parse_limit, forcing partial scans and a HIGH AE1 finding #628 / fix(analyzer): avoid false parse limits on quoted shell assignments #634) still produce AE1 as before.Tests
tests/nodes/test_finalize_inspection_ledger.pytest_reference_caveat_does_not_make_its_source_an_ae1_target[missing|ambiguous]test_self_reference_ae1_still_reports_source_content_limitations[missing|ambiguous]: a realstatic_parse_limitonSKILL.mdstill yields AE1, and its evidence excludes the reference caveat.test_other_reference_event_shapes_still_yield_self_reference_ae1: afailedoutcome or another phase with either reason, andreference_extraction_limit, all still yield AE1.tests/nodes/test_security_remediation.py::test_reference_caveat_does_not_turn_self_reference_into_ae1: full graph scan, parametrized over a backticked self-reference, a markdown-link self-reference, a markdown-link missing target, a lowercaseskill.mdprimary and an ambiguous reference. Each checks no AE1, the caveat retained,is_complete=falseandCAUTION.On
main, the four no-AE1 unit cases and all five end-to-end cases fail; they pass here. The out-of-shape cases pass onmaintoo, because they pin the AE1 that the filter must keep.Local runs:
ruff check src/ tests/andruff format --check src/ tests/pass.-m integration tests/integration/test_opaque_reference_reporting.py: 47 passed.-m "not integration and not provider"): 8403 passed, 14 skipped, 4 xfailed, 0 failed.Related
$(...)shell assignments in reference files longer than 4 KB triggerstatic_parse_limitand AE1. Tracked in static_patterns_tool_misuse: common bash idioms triggerstatic_parse_limit, forcing partial scans and a HIGH AE1 finding #628, with the fix in fix(analyzer): avoid false parse limits on quoted shell assignments #634.analysis-evasionHIGH, discarding the ledgerreason_codethat distinguishes them from real coverage failures #596 (AE1 severity ignoresreason_code) is the broader policy question and is not addressed here.🤖 Generated with Claude Code