@blamejs/exceptd-skills 0.18.5 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/exceptd.js +277 -60
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +24 -3
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +375 -40
  22. package/lib/prefetch.js +6 -1
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +201 -24
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +106 -13
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +19 -3
  33. package/lib/validate-indexes.js +62 -1
  34. package/lib/validate-playbooks.js +4 -1
  35. package/lib/validate-vendor.js +69 -8
  36. package/manifest.json +53 -53
  37. package/orchestrator/index.js +69 -13
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +124 -124
  40. package/scripts/audit-cross-skill.js +1 -1
  41. package/scripts/bootstrap.js +1 -0
  42. package/scripts/build-indexes.js +58 -4
  43. package/scripts/check-agents-md-collectors.js +41 -13
  44. package/scripts/check-changelog-extract.js +4 -4
  45. package/scripts/check-codebase-patterns.js +19 -5
  46. package/scripts/check-manifest-snapshot.js +74 -30
  47. package/scripts/check-sbom-currency.js +25 -5
  48. package/scripts/check-test-count.js +26 -7
  49. package/scripts/check-test-coverage.js +44 -4
  50. package/scripts/check-version-tags.js +27 -8
  51. package/scripts/predeploy.js +1 -1
  52. package/scripts/refresh-manifest-snapshot.js +14 -4
  53. package/scripts/refresh-reverse-refs.js +7 -1
  54. package/scripts/refresh-sbom.js +1 -1
  55. package/scripts/release.js +3 -3
  56. package/scripts/run-e2e-scenarios.js +18 -8
  57. package/scripts/validate-vendor-online.js +20 -2
  58. package/scripts/verify-shipped-tarball.js +65 -6
  59. package/sources/validators/cve-validator.js +17 -1
  60. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -102,15 +102,28 @@ function isLinkedWorktreeDir(dir) {
102
102
  * the per-file syscall. The symlink-cycle `seen` set still guards every
103
103
  * directory and symlink by canonical path.
104
104
  *
105
+ * Depth-cap visibility: a subtree pruned because it would exceed `maxDepth`
106
+ * is otherwise an invisible coverage gap — the files inside are never emitted
107
+ * and the caller cannot tell "scanned, nothing there" from "never scanned".
108
+ * Pass `opts.truncations` (an array) to have the walker push one
109
+ * `{ rel, depth }` entry per directory whose contents were skipped for being
110
+ * beyond the cap, so a content collector can surface a `depth_capped` notice
111
+ * symmetric with its per-file size-cap reporting. The return value is
112
+ * unchanged (the file array); callers that don't pass `truncations` are
113
+ * unaffected.
114
+ *
105
115
  * @param {string} root absolute scan root
106
116
  * @param {object} [opts]
107
117
  * @param {number} [opts.maxDepth] max recursion depth (collector-specific)
108
118
  * @param {Set<string>} [opts.excludes] directory basenames to skip
119
+ * @param {Array<{rel:string, depth:number}>} [opts.truncations] out-param the
120
+ * walker appends to when a directory is pruned for exceeding maxDepth
109
121
  * @returns {Array<{full:string, rel:string, name:string}>}
110
122
  */
111
123
  function walkTree(root, opts = {}) {
112
124
  const maxDepth = opts.maxDepth ?? 6;
113
125
  const excludes = opts.excludes ?? codeExcludeSet();
126
+ const truncations = Array.isArray(opts.truncations) ? opts.truncations : null;
114
127
  const out = [];
115
128
  const seen = new Set();
116
129
 
@@ -136,6 +149,18 @@ function walkTree(root, opts = {}) {
136
149
  // `.claude/worktrees/<id>/`; walking them rescans the same source
137
150
  // as the host tree and multiplies every hit.
138
151
  if (isLinkedWorktreeDir(full)) continue;
152
+ // Descending would land at depth+1 > maxDepth: the directory's
153
+ // contents are about to be dropped. Record the prune so the caller
154
+ // can report the unscanned subtree instead of silently missing it.
155
+ if (depth + 1 > maxDepth) {
156
+ if (truncations) {
157
+ truncations.push({
158
+ rel: path.relative(root, full).split(path.sep).join("/"),
159
+ depth: depth + 1,
160
+ });
161
+ }
162
+ continue;
163
+ }
139
164
  walk(full, depth + 1);
140
165
  } else if (entry.isSymbolicLink()) {
141
166
  // A symlink Dirent is neither isDirectory() nor isFile(). The old
@@ -83,11 +83,26 @@ const CONTENT_GATED_KEY_EXTS = new Set([".pem", ".key"]);
83
83
  function carriesPrivateKey(file) {
84
84
  const ext = path.extname(file.name).toLowerCase();
85
85
  if (!CONTENT_GATED_KEY_EXTS.has(ext)) return true; // .p12/.pfx/id_* → private by nature
86
+ let fd;
86
87
  try {
87
- if (fs.statSync(file.full).size > MAX_FILE_BYTES) return true; // too big to scan → conservative
88
- return PEM_PRIVATE_KEY_MARKER.test(fs.readFileSync(file.full, "utf8"));
88
+ fd = fs.openSync(file.full, "r");
89
+ const st = fs.fstatSync(fd); // fstat on the open fd — no stat-then-read race
90
+ if (st.size > MAX_FILE_BYTES) return true; // too big to scan → conservative
91
+ // Read the whole descriptor to EOF. A single readSync may return fewer
92
+ // bytes than requested (a short read on a network / FUSE / interrupted fd),
93
+ // which would leave the buffer's tail NUL-padded; if the BEGIN marker sat
94
+ // past that boundary — e.g. a .pem with a leading `Bag Attributes` /
95
+ // subject= header block, the default OpenSSL pkcs12→PEM layout — the marker
96
+ // test would return false and silently drop a real private key. readFileSync
97
+ // on the open fd loops internally until EOF, so the test always sees the
98
+ // complete content. Reading from the already-open fd keeps the no-TOCTOU
99
+ // open→fstat ordering (no second path lookup).
100
+ const content = fs.readFileSync(fd, "utf8");
101
+ return PEM_PRIVATE_KEY_MARKER.test(content);
89
102
  } catch {
90
103
  return true; // unreadable → conservative (treat as a key)
104
+ } finally {
105
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } }
91
106
  }
92
107
  }
93
108
 
@@ -227,7 +242,14 @@ const INDICATOR_PATTERNS = [
227
242
  // run halts at the first `-` of `-----END` (no backtracking, ReDoS-safe).
228
243
  // Private-key *files* (.pem/.key) are detected separately by file presence.
229
244
  { id: "ssh-private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |)PRIVATE KEY-----[A-Za-z0-9+/=\s]{20,4000}-----END/g },
230
- { id: "openai-api-key", re: /\bsk-(?:proj-|svcacct-|admin-|)[A-Za-z0-9_-]{20,}\b/g },
245
+ // The `(?!ant-)` lookahead keeps an Anthropic `sk-ant-*` key from also
246
+ // matching here. Without it the trailing empty alternative made the prefix
247
+ // optional (`sk-<20+ chars>`), so one Anthropic key double-fired as both
248
+ // openai-api-key and anthropic-api-key — inflating the hit count and
249
+ // mislabeling the vendor. The optional `(?:proj-|svcacct-|admin-)?` group
250
+ // still admits every real OpenAI shape (proj/svcacct/admin and the bare
251
+ // legacy `sk-` key); the lookahead is anchored, so no backtracking / ReDoS.
252
+ { id: "openai-api-key", re: /\bsk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}\b/g },
231
253
  { id: "anthropic-api-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
232
254
  ];
233
255
 
@@ -339,12 +361,26 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
339
361
  const root = path.resolve(cwd);
340
362
 
341
363
  let files;
364
+ const truncations = [];
342
365
  try {
343
- files = walkTree(root, { maxDepth: DEFAULT_MAX_DEPTH, excludes: DEFAULT_EXCLUDES });
366
+ files = walkTree(root, { maxDepth: DEFAULT_MAX_DEPTH, excludes: DEFAULT_EXCLUDES, truncations });
344
367
  } catch (e) {
345
368
  errors.push({ kind: "walk_failed", reason: e.message });
346
369
  files = [];
347
370
  }
371
+ // Subtrees pruned for exceeding the depth cap are NOT scanned. Surface them
372
+ // so an unscanned deep .env / id_rsa / api-key file is an observable gap,
373
+ // not a silent miss indistinguishable from "scanned, found nothing" —
374
+ // symmetric with the per-file `file_too_large_skipped` reporting below.
375
+ if (truncations.length) {
376
+ errors.push({
377
+ artifact_id: "repo-tree",
378
+ kind: "depth_capped",
379
+ reason: `${truncations.length} subtree(s) beyond depth ${DEFAULT_MAX_DEPTH} not scanned for secrets: ${truncations.slice(0, 20).map(t => t.rel).join(", ")}${truncations.length > 20 ? ", …" : ""}`,
380
+ truncated_paths: truncations.slice(0, 50).map(t => t.rel),
381
+ truncated_count: truncations.length,
382
+ });
383
+ }
348
384
  if (files.length > 50000) {
349
385
  errors.push({
350
386
  kind: "file_count_capped",
@@ -42,7 +42,19 @@ const path = require("path");
42
42
  // 3. Validates the post-apply entry against lib/schemas/cve-catalog.schema.json
43
43
  // before deciding promotion.
44
44
  const { withCatalogLock } = require("./refresh-external");
45
- const { validate: validateAgainstSchema } = require("./validate-cve-catalog");
45
+ // `validate` is the LOOSE JSON-schema checker; `additionalChecks` is the
46
+ // strict rule set (STRICT_CVSS_PATTERN, isUsableDate on every date field,
47
+ // cisa_kev=true-requires-valid-date, cross-catalog ref resolution, Hard-Rule
48
+ // #14 IoCs). The predeploy gate runs `validate-cve-catalog.js --strict`, which
49
+ // promotes additionalChecks warnings to FATAL errors on NON-draft entries. The
50
+ // apply path must gate promotion on BOTH — otherwise it strips the _draft
51
+ // markers off an entry the very next predeploy run then FATAL-rejects.
52
+ const {
53
+ validate: validateAgainstSchema,
54
+ additionalChecks,
55
+ STRICT_CVSS_PATTERN,
56
+ isUsableDate,
57
+ } = require("./validate-cve-catalog");
46
58
  // derive rwep_score via the canonical scoring helper rather
47
59
  // than a blind `Object.values(...).reduce(sum)`. The helper detects shape
48
60
  // (boolean inputs → scoreCustom; post-weight numeric inputs → sum + clamp)
@@ -107,6 +119,30 @@ function loadJson(relOrAbs) {
107
119
  return v;
108
120
  }
109
121
 
122
+ // Build the cross-reference context additionalChecks() consumes — the same
123
+ // id Sets lib/validate-cve-catalog.js#main() assembles. Cached per process
124
+ // (the underlying catalogs are immutable inside one CLI run). A `null` Set
125
+ // (catalog absent) makes additionalChecks() skip that ref family, matching
126
+ // the validator's defense-in-depth behaviour.
127
+ let _strictCtxCache = null;
128
+ function strictCheckCtx() {
129
+ if (_strictCtxCache) return _strictCtxCache;
130
+ const keySet = (relPath) => {
131
+ const cat = loadJson(relPath);
132
+ return cat && typeof cat === "object"
133
+ ? new Set(Object.keys(cat).filter((k) => !k.startsWith("_")))
134
+ : null;
135
+ };
136
+ _strictCtxCache = {
137
+ atlasKeys: keySet("data/atlas-ttps.json"),
138
+ cweKeys: keySet("data/cwe-catalog.json"),
139
+ attackKeys: keySet("data/attack-techniques.json"),
140
+ d3fendKeys: keySet("data/d3fend-catalog.json"),
141
+ frameworkKeys: keySet("data/framework-control-gaps.json"),
142
+ };
143
+ return _strictCtxCache;
144
+ }
145
+
110
146
  /**
111
147
  * Score a candidate by counting keyword overlap with the draft entry.
112
148
  * Returns 0..100. Pure heuristic — reviewer makes the final call.
@@ -519,19 +555,42 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
519
555
  // is ignored (so a typo'd key in answers.json doesn't pollute the entry).
520
556
  // Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
521
557
  const id = (x) => x;
558
+
559
+ // rwep_factors must arrive as the post-weight (Shape B) block the schema
560
+ // mandates: a non-array object whose EVERY value is a finite number. A
561
+ // permissive "any object" check let a mixed-shape bag through — post-weight
562
+ // integers carrying a string active_exploitation, or an all-numeric block
563
+ // plus a rogue extra key holding a ladder string ('confirmed'). Either
564
+ // shape flips deriveRwepFromFactors() onto its Shape-A (scoreCustom) route,
565
+ // which reads the post-weight integers as booleans and DROPS the unmapped
566
+ // ai_factor weight — deriving a silently wrong rwep_score (e.g. 55/75 for a
567
+ // bag the operator intended as 90) and writing it to the catalog. Rejecting
568
+ // any non-numeric value here turns that silent mis-score into a surfaced
569
+ // rejected_fields entry at apply time, instead of relying on the downstream
570
+ // scoring.validate() divergence gate (which runs only at predeploy, after
571
+ // the wrong score is already on disk).
572
+ const isPostWeightFactors = (v) =>
573
+ v && typeof v === "object" && !Array.isArray(v) &&
574
+ Object.values(v).every((x) => typeof x === "number" && Number.isFinite(x));
575
+
522
576
  const ALLOWED = [
523
577
  ["framework_control_gaps", (v) => v && typeof v === "object" && !Array.isArray(v), id],
524
578
  ["iocs", (v) => v && typeof v === "object" && !Array.isArray(v), id],
525
579
  ["atlas_refs", (v) => Array.isArray(v), id],
526
580
  ["attack_refs", (v) => Array.isArray(v), id],
527
- ["rwep_factors", (v) => v && typeof v === "object" && !Array.isArray(v), id],
581
+ ["rwep_factors", isPostWeightFactors, id],
528
582
  ["rwep_score", (v) => typeof v === "number", id],
529
583
  ["rwep_notes", (v) => typeof v === "string", id],
530
584
  ["cvss_score", (v) => typeof v === "number", id],
531
- ["cvss_vector", (v) => typeof v === "string" && /^CVSS:[0-9]/.test(v), id],
585
+ // Match the predeploy strict gate at apply time: a non-existent CVSS
586
+ // version (`CVSS:9/...`) and a malformed/impossible KEV date previously
587
+ // survived the per-field check, landed on the entry, then blocked
588
+ // promotion downstream. Rejecting them here surfaces the exact field as a
589
+ // rejected_fields entry instead of a silent block.
590
+ ["cvss_vector", (v) => typeof v === "string" && STRICT_CVSS_PATTERN.test(v), id],
532
591
  ["cisa_kev", (v) => typeof v === "boolean", id],
533
- ["cisa_kev_date", (v) => v === null || typeof v === "string", id],
534
- ["cisa_kev_due_date", (v) => v === null || typeof v === "string", id],
592
+ ["cisa_kev_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
593
+ ["cisa_kev_due_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
535
594
  ["poc_available", (v) => typeof v === "boolean", id],
536
595
  ["poc_description", (v) => typeof v === "string", id],
537
596
  ["ai_discovered", (v) => typeof v === "boolean", id],
@@ -597,8 +656,20 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
597
656
  const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
598
657
  if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
599
658
  }
659
+ // The loose schema accepts values the predeploy `validate-cve-catalog.js
660
+ // --strict` gate FATAL-rejects on a non-draft entry: a cvss_vector for a
661
+ // non-existent CVSS version (`CVSS:9/...` passes the schema's
662
+ // `^CVSS:[0-9]+(\.[0-9]+)?/` but fails STRICT_CVSS_PATTERN), a cisa_kev=true
663
+ // with a malformed/impossible cisa_kev_date, an atlas/attack/cwe/d3fend/
664
+ // framework ref that resolves to no catalog entry, or a poc+public-exploit
665
+ // source with no iocs. Run the SAME additionalChecks() the validator runs
666
+ // and fold its findings into the promotion gate, so the apply path never
667
+ // strips _draft off an entry the next predeploy run then rejects. Surfaced
668
+ // under a dedicated `strict_warnings` key so the operator sees exactly which
669
+ // strict rule blocked promotion.
670
+ const strictWarnings = additionalChecks(cveId, entry, strictCheckCtx());
600
671
  let promoted = false;
601
- if (warnings.length === 0 && schemaErrors.length === 0) {
672
+ if (warnings.length === 0 && schemaErrors.length === 0 && strictWarnings.length === 0) {
602
673
  delete entry._auto_imported;
603
674
  delete entry._draft;
604
675
  delete entry._draft_reason;
@@ -621,15 +692,20 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
621
692
  is_draft: !promoted,
622
693
  residual_warnings: warnings,
623
694
  schema_errors: schemaErrors,
695
+ strict_warnings: strictWarnings,
624
696
  catalog_path: catalogPath,
625
697
  next_steps: promoted
626
698
  ? [
627
- `Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --quiet\` to confirm it passes the strict schema gate.`,
699
+ `Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --strict --quiet\` to confirm it passes the predeploy gate.`,
628
700
  `Add a matching entry to data/zeroday-lessons.json (AGENTS.md rule #6).`,
629
701
  `Run \`npm run predeploy\` before tagging.`,
630
702
  ]
631
703
  : [
632
- `Entry remains a DRAFT (${warnings.length} required field(s) still unpopulated). Supply answers for: ${warnings.slice(0, 6).join(", ")}${warnings.length > 6 ? ", ..." : ""}.`,
704
+ warnings.length > 0
705
+ ? `Entry remains a DRAFT (${warnings.length} required field(s) still unpopulated). Supply answers for: ${warnings.slice(0, 6).join(", ")}${warnings.length > 6 ? ", ..." : ""}.`
706
+ : (schemaErrors.length > 0
707
+ ? `Entry remains a DRAFT — schema validation failed: ${schemaErrors.slice(0, 4).join("; ")}${schemaErrors.length > 4 ? "; ..." : ""}.`
708
+ : `Entry remains a DRAFT — the predeploy --strict checks rejected it: ${strictWarnings.slice(0, 4).join("; ")}${strictWarnings.length > 4 ? "; ..." : ""}.`),
633
709
  `Re-run \`exceptd refresh --curate ${cveId}\` to see the updated questionnaire.`,
634
710
  ],
635
711
  };
@@ -679,7 +679,7 @@ function lintSkill(entry, ctx) {
679
679
  // on their own cadence and the catalog frequently carries
680
680
  // auto-imported drafts that skills can legitimately cite.
681
681
  if (ctx.cveCatalog && body && typeof body === 'string') {
682
- const cveRefRe = /\b(CVE-(?:19|20)\d{2}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
682
+ const cveRefRe = /\b(CVE-\d{4}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
683
683
  const seen = new Set();
684
684
  let m;
685
685
  while ((m = cveRefRe.exec(body)) !== null) {
@@ -819,6 +819,59 @@ function findOrphanSkillFiles(manifestSkills) {
819
819
  return orphans;
820
820
  }
821
821
 
822
+ // Manifest cover arrays that must resolve to a real catalog entry, paired
823
+ // with the loadContext() key-set they resolve against. The manifest is an
824
+ // enriched superset of frontmatter (it may carry curated refs absent from
825
+ // any skill body), and those manifest-only refs are what build-indexes'
826
+ // reverse-ref surface and refresh-reverse-refs read — yet the per-skill
827
+ // frontmatter ref-resolution above never sees them. Without this pass a
828
+ // typo'd or stale manifest-only ref (e.g. a hand-edit, or one re-signed
829
+ // into manifest_signature) becomes an orphaned control reference in the
830
+ // signed manifest + every derived surface, the exact "no orphaned controls"
831
+ // failure (AGENTS.md Hard Rule #4) the frontmatter resolution prevents —
832
+ // applied to the manifest-only delta the frontmatter pass is blind to.
833
+ const MANIFEST_COVER_RESOLUTION = [
834
+ { field: 'atlas_refs', ctxKey: 'atlasKeys', catalog: 'data/atlas-ttps.json' },
835
+ { field: 'attack_refs', ctxKey: 'attackKeys', catalog: 'data/attack-techniques.json' },
836
+ { field: 'framework_gaps', ctxKey: 'frameworkKeys', catalog: 'data/framework-control-gaps.json' },
837
+ { field: 'rfc_refs', ctxKey: 'rfcKeys', catalog: 'data/rfc-references.json' },
838
+ { field: 'cwe_refs', ctxKey: 'cweKeys', catalog: 'data/cwe-catalog.json' },
839
+ { field: 'd3fend_refs', ctxKey: 'd3fendKeys', catalog: 'data/d3fend-catalog.json' },
840
+ { field: 'dlp_refs', ctxKey: 'dlpKeys', catalog: 'data/dlp-controls.json' },
841
+ ];
842
+
843
+ /*
844
+ * Manifest cover-array ref resolution. For every manifest skill entry,
845
+ * assert each ref in its cross-reference cover arrays resolves to a
846
+ * top-level non-underscore key in the matching catalog. This extends the
847
+ * per-skill frontmatter ref-resolution (which reads only skill bodies) to
848
+ * the MANIFEST cover arrays — the signed, index-feeding source of truth
849
+ * that build-indexes' reverse-ref surface and refresh-reverse-refs read.
850
+ *
851
+ * A ctxKey set that is null (the optional attack-techniques.json catalog is
852
+ * absent in older trees) skips that field, mirroring loadContext()'s
853
+ * graceful-degradation contract for ctx.attackKeys.
854
+ *
855
+ * @returns {string[]} `<skill>.<field>: <ref> not present in <catalog>` lines
856
+ */
857
+ function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
858
+ const errors = [];
859
+ for (const entry of manifestSkills) {
860
+ const name = entry.name || entry.id || '<unknown>';
861
+ for (const { field, ctxKey, catalog } of MANIFEST_COVER_RESOLUTION) {
862
+ const keySet = ctx[ctxKey];
863
+ if (!keySet) continue; // catalog absent — degrade gracefully
864
+ const refs = Array.isArray(entry[field]) ? entry[field] : [];
865
+ for (const ref of refs) {
866
+ if (!keySet.has(ref)) {
867
+ errors.push(`${name}.${field}: "${ref}" not present in ${catalog}`);
868
+ }
869
+ }
870
+ }
871
+ }
872
+ return errors;
873
+ }
874
+
822
875
  // Substrings that indicate an artifact `source` makes a network call. Used
823
876
  // by lintPlaybookAirGap() to flag artifacts that lack an air_gap_alternative.
824
877
  // Conservative-by-design — false positives are surfaced as `warn` (not
@@ -917,6 +970,7 @@ function main() {
917
970
  // A targeted single-skill lint is for diagnosing one entry; running
918
971
  // the orphan walk there would surface unrelated findings.
919
972
  let orphans = [];
973
+ let manifestRefErrors = [];
920
974
  let airGapWarnings = [];
921
975
  if (!opts.skill) {
922
976
  orphans = findOrphanSkillFiles(manifest.skills);
@@ -925,6 +979,18 @@ function main() {
925
979
  console.log(` - skill.md exists on disk but not in manifest: ${o}`);
926
980
  console.log(` fix: re-run sign-all (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) after adding it to manifest.json, OR delete the orphan directory`);
927
981
  }
982
+ // Manifest cover-array ref resolution (Hard Rule #4 — no orphaned
983
+ // controls). The per-skill pass above resolves frontmatter refs; this
984
+ // resolves the manifest cover arrays the reverse-ref surface reads, so a
985
+ // typo'd/stale manifest-only ref can't ship as an orphaned control. A
986
+ // hard failure (not a --strict warning): an unresolved control ref is
987
+ // unconditionally wrong, matching the frontmatter ref-resolution voice.
988
+ manifestRefErrors = findUnresolvedManifestCoverRefs(manifest.skills, ctx);
989
+ for (const e of manifestRefErrors) {
990
+ console.log(`FAIL <manifest-cover-ref>`);
991
+ console.log(` - ${e}`);
992
+ console.log(` fix: correct the typo'd/stale ref in manifest.json (or add the entry to the catalog), then re-run sign-all + refresh-reverse-refs + build-indexes`);
993
+ }
928
994
  // P4 — air-gap completeness lint over data/playbooks/*.json.
929
995
  airGapWarnings = lintPlaybookAirGap();
930
996
  for (const w of airGapWarnings) {
@@ -938,12 +1004,15 @@ function main() {
938
1004
  const total = results.length;
939
1005
  const passed = total - failed - warned;
940
1006
  const orphanSummary = orphans.length ? `, ${orphans.length} orphan skill.md file(s)` : '';
1007
+ const manifestRefSummary = manifestRefErrors.length
1008
+ ? `, ${manifestRefErrors.length} unresolved manifest cover ref(s)`
1009
+ : '';
941
1010
  const warnSummary = warned ? `, ${warned} with warnings` : '';
942
1011
  const airGapSummary = airGapWarnings && airGapWarnings.length
943
1012
  ? `, ${airGapWarnings.length} playbook artifact(s) missing air_gap_alternative`
944
1013
  : '';
945
1014
  console.log(
946
- `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${airGapSummary}.`,
1015
+ `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${manifestRefSummary}${airGapSummary}.`,
947
1016
  );
948
1017
  // --strict treats any warning (per-skill or playbook air-gap) as a
949
1018
  // release-blocking failure so a warned regression cannot ship silently.
@@ -951,7 +1020,7 @@ function main() {
951
1020
  if (strictFail) {
952
1021
  console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
953
1022
  }
954
- safeExit(failed === 0 && orphans.length === 0 && !strictFail ? 0 : 1);
1023
+ safeExit(failed === 0 && orphans.length === 0 && manifestRefErrors.length === 0 && !strictFail ? 0 : 1);
955
1024
  return;
956
1025
  }
957
1026
 
@@ -962,6 +1031,9 @@ module.exports = {
962
1031
  extractFrontmatterBlock,
963
1032
  unquote,
964
1033
  findOrphanSkillFiles,
1034
+ findUnresolvedManifestCoverRefs,
1035
+ loadContext,
1036
+ MANIFEST_COVER_RESOLUTION,
965
1037
  findMissingSections,
966
1038
  lintPlaybookAirGap,
967
1039
  PLAYBOOK_NET_PATTERNS,