@blamejs/exceptd-skills 0.12.11 → 0.12.15

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 (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +339 -0
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
@@ -89,10 +89,49 @@ function git(args, cwd) {
89
89
  // findings masked. Codex P1 flag on PR #2 of v0.12.8.
90
90
  function resolveBaseRef(opts, cwd) {
91
91
  if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
92
+ // F14 — fall back gracefully when origin/main is unreachable. The
93
+ // original implementation tried `merge-base HEAD <opts.base>` and, on
94
+ // failure, returned opts.base verbatim — which then failed every
95
+ // subsequent git invocation, surfacing as a runner-level error. In CI
96
+ // (full clones) the original ref usually resolves; on a developer
97
+ // laptop without `origin/main` configured (fresh clone, detached
98
+ // worktree, alternative remote name) the gate would fail entirely.
99
+ //
100
+ // Order of preference:
101
+ // 1. merge-base against the requested base
102
+ // 2. requested base verbatim, if `git rev-parse --verify` resolves it
103
+ // 3. local `main` HEAD if it exists
104
+ // 4. HEAD~1 as a last resort (single-commit diff)
105
+ const tryResolve = (ref) => {
106
+ try {
107
+ git(["merge-base", "HEAD", ref], cwd).trim();
108
+ return ref;
109
+ } catch { /* not resolvable */ }
110
+ try {
111
+ git(["rev-parse", "--verify", ref], cwd).trim();
112
+ return ref;
113
+ } catch { return null; }
114
+ };
92
115
  try {
93
116
  const mb = git(["merge-base", "HEAD", opts.base], cwd).trim();
94
117
  if (mb) return mb;
95
- } catch { /* base may not exist locally; fall back to ref literal */ }
118
+ } catch { /* fall through */ }
119
+ const direct = tryResolve(opts.base);
120
+ if (direct) return direct;
121
+ const local = tryResolve("main");
122
+ if (local) {
123
+ process.stderr.write(
124
+ `[check-test-coverage] WARN: ${opts.base} unreachable; falling back to local main\n`
125
+ );
126
+ return local;
127
+ }
128
+ const parent = tryResolve("HEAD~1");
129
+ if (parent) {
130
+ process.stderr.write(
131
+ `[check-test-coverage] WARN: ${opts.base} unreachable and no local main; falling back to HEAD~1\n`
132
+ );
133
+ return parent;
134
+ }
96
135
  return opts.base;
97
136
  }
98
137
 
@@ -165,6 +204,23 @@ function categorize(file) {
165
204
  if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
166
205
  if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
167
206
  if (norm === "data/cve-catalog.json") return "cve-catalog";
207
+ // F11 — files matching catalog/schema/SBOM shapes are surfaced for manual
208
+ // review rather than silent allowlist. These changes (manifest.json,
209
+ // schemas/*, data/*.json, sbom.cdx.json, manifest-snapshot.*) can carry
210
+ // semantic surface but the analyzer has no syntactic surface extractor
211
+ // for them — humans should look.
212
+ if (norm === "manifest.json") return "manual-review";
213
+ if (norm === "manifest-snapshot.json") return "manual-review";
214
+ if (norm === "manifest-snapshot.sha256") return "manual-review";
215
+ if (norm === "sbom.cdx.json") return "manual-review";
216
+ if (norm.startsWith("lib/schemas/")) return "manual-review";
217
+ // v0.12.14: data/_indexes/ is auto-regenerated from data/ + manifest by
218
+ // `npm run build-indexes`; the source-of-truth diff is in the data/
219
+ // files themselves. Allowlist the derived index files so they don't
220
+ // perpetually surface as manual-review on every release commit.
221
+ if (norm.startsWith("data/_indexes/")) return "allowlist-derived";
222
+ if (norm.startsWith("data/") && norm.endsWith(".json")) return "manual-review";
223
+ if (norm === "package.json") return "manual-review";
168
224
  return "other";
169
225
  }
170
226
 
@@ -271,15 +327,20 @@ function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return
271
327
 
272
328
  function loadTestCorpus(cwd) {
273
329
  const root = path.join(cwd, "tests");
274
- if (!fs.existsSync(root)) return "";
330
+ if (!fs.existsSync(root)) return { joined: "", files: [] };
275
331
  const acc = [];
332
+ const files = [];
276
333
  walk(root, p => {
277
334
  const norm = p.replace(/\\/g, "/");
278
335
  if (/\.(js|json)$/.test(norm)) {
279
- try { acc.push(fs.readFileSync(p, "utf8")); } catch { /* ignore unreadable */ }
336
+ try {
337
+ const content = fs.readFileSync(p, "utf8");
338
+ acc.push(content);
339
+ files.push({ path: norm, content });
340
+ } catch { /* ignore unreadable */ }
280
341
  }
281
342
  });
282
- return acc.join("\n\x00\n");
343
+ return { joined: acc.join("\n\x00\n"), files };
283
344
  }
284
345
 
285
346
  function walk(dir, fn) {
@@ -302,22 +363,76 @@ function coversCliFlag(corpus, flag) {
302
363
  return corpus.includes(flag);
303
364
  }
304
365
 
366
+ // F10 — same-file context check. A test corpus is no longer treated as
367
+ // one giant string for lib-export coverage: the identifier must appear
368
+ // inside a real test block (`test(`, `it(`, `describe(`, or an `assert(`
369
+ // argument) within the SAME file that issues the matching require().
370
+ // Pre-fix: an `assert.equal(...)` mention in one test file plus a stray
371
+ // `require('../lib/x')` in a completely different test file counted as
372
+ // coverage. That's not coverage — it's textual coincidence.
373
+ //
374
+ // `corpus` may be either a string (legacy joined corpus, used by
375
+ // CLI/playbook/CVE coverage probes) or the structured shape
376
+ // `{ joined, files }` produced by loadTestCorpus().
305
377
  function coversLibExport(corpus, libRel, ident) {
306
378
  const baseName = path.basename(libRel).replace(/\.js$/, "");
307
379
  const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
308
380
  const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
309
- // Primary: a `require()` that mentions the module name AND a reference
310
- // to the identifier anywhere in the test corpus. Catches the canonical
311
- // `const { foo } = require('../lib/x')` test shape.
312
381
  const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
313
- if (requireRe.test(corpus) && identRe.test(corpus)) return true;
314
- // v0.12.9: a test that spawns the script under test (e.g.
315
- // `spawnSync(node, [".../scripts/check-sbom-currency.js", ...])`) is
316
- // real coverage too. Accept that shape when the corpus references the
317
- // full filename AND the identifier elsewhere. The `.js` suffix is what
318
- // distinguishes a real spawn-path from an arbitrary mention of the
319
- // module base name.
320
- if (corpus.includes(baseFile) && identRe.test(corpus)) return true;
382
+ // Accept the structured shape (preferred). Walk files individually.
383
+ if (corpus && Array.isArray(corpus.files)) {
384
+ for (const f of corpus.files) {
385
+ const hasRequire = requireRe.test(f.content);
386
+ const mentionsSpawnPath = f.content.includes(baseFile);
387
+ if (!hasRequire && !mentionsSpawnPath) continue;
388
+ if (!identRe.test(f.content)) continue;
389
+ // F10 require the identifier appears inside a test block in this
390
+ // file. Recognise `test(`, `it(`, `describe(`, or `assert(` (or any
391
+ // `assert.<member>(`) bracketed argument that mentions the ident.
392
+ if (mentionsIdentInTestContext(f.content, ident)) return true;
393
+ }
394
+ return false;
395
+ }
396
+ // Fallback: legacy joined-string corpus.
397
+ const joined = typeof corpus === "string" ? corpus : (corpus && corpus.joined) || "";
398
+ if (requireRe.test(joined) && identRe.test(joined)) return true;
399
+ if (joined.includes(baseFile) && identRe.test(joined)) return true;
400
+ return false;
401
+ }
402
+
403
+ // Returns true when `ident` appears as a token inside the body of any
404
+ // `test( ... )`, `it( ... )`, `describe( ... )`, `assert( ... )` or
405
+ // `assert.<member>( ... )` call in the file. We approximate "the body of
406
+ // the call" by finding the opening paren after the keyword, then walking
407
+ // matched parens until the call closes. This is a syntactic-enough check
408
+ // for vanilla JavaScript tests; the goal is to refuse "ident only appears
409
+ // in a top-level comment" while still accepting `assert.deepEqual(foo, ...)`.
410
+ function mentionsIdentInTestContext(content, ident) {
411
+ const tokenRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
412
+ // Quick reject: file does not mention the identifier at all.
413
+ if (!tokenRe.test(content)) return false;
414
+ const callRe = /\b(test|it|describe|assert(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
415
+ let m;
416
+ while ((m = callRe.exec(content)) !== null) {
417
+ const start = m.index + m[0].length; // pointer to first char inside (
418
+ let depth = 1;
419
+ let i = start;
420
+ let inStr = null;
421
+ while (i < content.length && depth > 0) {
422
+ const c = content[i];
423
+ if (inStr) {
424
+ if (c === "\\") { i += 2; continue; }
425
+ if (c === inStr) inStr = null;
426
+ } else {
427
+ if (c === '"' || c === "'" || c === "`") inStr = c;
428
+ else if (c === "(") depth++;
429
+ else if (c === ")") depth--;
430
+ }
431
+ i++;
432
+ }
433
+ const body = content.slice(start, i - 1);
434
+ if (tokenRe.test(body)) return true;
435
+ }
321
436
  return false;
322
437
  }
323
438
 
@@ -341,7 +456,8 @@ function analyze(opts) {
341
456
  // between calls produces false add/remove findings.
342
457
  const resolvedBase = resolveBaseRef(opts, cwd);
343
458
  const changed = listChangedFiles(opts, cwd, resolvedBase);
344
- const corpus = loadTestCorpus(cwd);
459
+ const corpusObj = loadTestCorpus(cwd);
460
+ const corpus = corpusObj.joined;
345
461
 
346
462
  const findings = [];
347
463
  const allowlisted = [];
@@ -355,7 +471,12 @@ function analyze(opts) {
355
471
  }
356
472
  if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
357
473
  if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
358
- if (cat === "other") { allowlisted.push({ file: ch.file, reason: "out-of-scope" }); continue; }
474
+ // F11 data catalogs, schemas, manifests, SBOM go to manual review
475
+ // instead of being silently allowlisted. They show up in CI output.
476
+ if (cat === "manual-review") { manualReview.push({ file: ch.file, reason: "manual-review" }); continue; }
477
+ // v0.12.14: derived index files allowlist (auto-regenerated artifacts).
478
+ if (cat === "allowlist-derived") { allowlisted.push({ file: ch.file, reason: "derived-artifact" }); continue; }
479
+ if (cat === "other") { manualReview.push({ file: ch.file, reason: "unclassified" }); continue; }
359
480
  if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
360
481
  allowlisted.push({ file: ch.file, reason: "whitespace-only" });
361
482
  continue;
@@ -381,9 +502,11 @@ function analyze(opts) {
381
502
  const b = extractLibExports(before);
382
503
  const a = extractLibExports(after);
383
504
  const d = diffSets(b, a);
384
- for (const id of d.added) if (!coversLibExport(corpus, ch.file, id))
505
+ // F10 pass the structured corpus so coversLibExport can enforce
506
+ // same-file require()+identifier-in-test-context coverage.
507
+ for (const id of d.added) if (!coversLibExport(corpusObj, ch.file, id))
385
508
  findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
386
- for (const id of d.removed) if (coversLibExport(corpus, ch.file, id))
509
+ for (const id of d.removed) if (coversLibExport(corpusObj, ch.file, id))
387
510
  findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
388
511
  } else if (cat === "playbook") {
389
512
  const b = extractPlaybookIds(before);
@@ -19,6 +19,15 @@
19
19
  * Single-source-of-truth: the GATES list below mirrors the job sequence
20
20
  * in .github/workflows/ci.yml. Test coverage in tests/predeploy.test.js
21
21
  * asserts the two stay in sync.
22
+ *
23
+ * Audit G F5 — when the manifest-snapshot gate fails, the fix is NOT to
24
+ * run `npm run refresh-snapshot` blindly. The refresh script now refuses
25
+ * unless the operator passes `--commit-only` or sets
26
+ * EXCEPTD_SNAPSHOT_AUDIT_ACK=1. This is intentional: a failing snapshot
27
+ * gate means a breaking change was detected, and an accidental refresh
28
+ * would silently rewrite the baseline. Read the breaking-change list
29
+ * first, then run `node scripts/refresh-manifest-snapshot.js --commit-only`
30
+ * if the change is intentional.
22
31
  */
23
32
 
24
33
  const { execFileSync } = require("child_process");
@@ -62,28 +71,15 @@ const GATES = [
62
71
  args: [path.join(ROOT, "lib", "validate-cve-catalog.js")],
63
72
  ciJobName: "Data integrity (catalog + manifest snapshot)",
64
73
  },
65
- {
66
- name: "Validate offline CVE catalog state",
67
- command: process.execPath,
68
- args: [
69
- path.join(ROOT, "orchestrator", "index.js"),
70
- "validate-cves",
71
- "--offline",
72
- "--no-fail",
73
- ],
74
- ciJobName: "Data integrity (catalog + manifest snapshot)",
75
- },
76
- {
77
- name: "Validate offline RFC catalog state",
78
- command: process.execPath,
79
- args: [
80
- path.join(ROOT, "orchestrator", "index.js"),
81
- "validate-rfcs",
82
- "--offline",
83
- "--no-fail",
84
- ],
85
- ciJobName: "Data integrity (catalog + manifest snapshot)",
86
- },
74
+ // Audit G F13 — the "validate-cves --offline --no-fail" and
75
+ // "validate-rfcs --offline --no-fail" gates were enumeration-only sanity
76
+ // checks: `--no-fail` forced them to always exit 0, so they never blocked
77
+ // a release on a real catalog problem. The deep catalog validation is
78
+ // already performed by the gate above (`lib/validate-cve-catalog.js`),
79
+ // including cross-catalog reference resolution after this same audit.
80
+ // Keeping the no-op gates as predeploy steps inflated the gate count for
81
+ // no marginal value and risked false confidence ("X gates passed"). They
82
+ // are removed in v0.12.14; document the removal in CHANGELOG.
87
83
  {
88
84
  name: "Manifest snapshot gate (breaking-change detector)",
89
85
  command: process.execPath,
@@ -97,9 +93,13 @@ const GATES = [
97
93
  ciJobName: "Lint skill files",
98
94
  },
99
95
  {
100
- // Informational only — surfaces the forward_watch horizon across all
101
- // skills as a sanity signal. Emits the count but never fails the run;
102
- // a parse problem is reported, not blocking.
96
+ // Informational — surfaces the forward_watch horizon across all skills.
97
+ // Audit G F12: an exit code of 0 means "ok", 1 means "items present
98
+ // (informational)", 2+ means a runtime error in the gate itself.
99
+ // The runner now distinguishes the two: 0/1 stay informational, 2+
100
+ // surface as a real failure. Pre-fix, any non-zero exit was rolled up
101
+ // as informational, which hid crashes (a 137 OOM looked the same as
102
+ // "found 12 items to review").
103
103
  name: "Forward-watch aggregator (informational)",
104
104
  command: process.execPath,
105
105
  args: [
@@ -108,6 +108,7 @@ const GATES = [
108
108
  ],
109
109
  ciJobName: "Data integrity (catalog + manifest snapshot)",
110
110
  informational: true,
111
+ informationalMaxExitCode: 1,
111
112
  },
112
113
  {
113
114
  name: "Validate catalog _meta (tlp + source_confidence + freshness_policy)",
@@ -164,6 +165,19 @@ const GATES = [
164
165
  args: [path.join(ROOT, "scripts", "check-test-coverage.js")],
165
166
  ciJobName: "Diff coverage",
166
167
  },
168
+ {
169
+ // v0.12.12 — Validate every playbook in data/playbooks/ against the
170
+ // JSON schema + cross-playbook + cross-catalog references. Runs as
171
+ // informational (warnings, not failures) for v0.12.12 so the patch-
172
+ // class release can land without retroactively breaking schema-drift
173
+ // cases that operators have not yet reconciled. v0.13.0 will flip
174
+ // `informational: false`.
175
+ name: "Validate playbooks (schema + cross-refs, informational)",
176
+ command: process.execPath,
177
+ args: [path.join(ROOT, "lib", "validate-playbooks.js")],
178
+ ciJobName: "Validate playbooks",
179
+ informational: true,
180
+ },
167
181
  ];
168
182
 
169
183
  function runGate(gate) {
@@ -174,26 +188,70 @@ function runGate(gate) {
174
188
  status: "skipped",
175
189
  reason:
176
190
  "keys/public.pem missing — run `npm run bootstrap` to generate keys + sign skills.",
191
+ durationMs: 0,
177
192
  };
178
193
  }
179
194
  }
180
- try {
181
- execFileSync(gate.command, gate.args, { stdio: "inherit", cwd: ROOT });
182
- return { status: "passed" };
183
- } catch (e) {
184
- if (gate.informational) {
195
+ const t0 = Date.now();
196
+ // Audit G F21 — spawn the child with piped stdio + tee to the parent so we
197
+ // can count `WARN ` lines for the summary table. We still want the live
198
+ // output, so each chunk is forwarded as it arrives.
199
+ const { spawnSync } = require("child_process");
200
+ const r = spawnSync(gate.command, gate.args, {
201
+ cwd: ROOT,
202
+ encoding: "utf8",
203
+ maxBuffer: 64 * 1024 * 1024,
204
+ });
205
+ const durationMs = Date.now() - t0;
206
+ if (r.stdout) process.stdout.write(r.stdout);
207
+ if (r.stderr) process.stderr.write(r.stderr);
208
+ // Count WARN-labelled lines in the combined stream so the summary table
209
+ // can surface them. Lint / validate output uses "WARN " at line start;
210
+ // count both the table form and an inline "[warn]" form.
211
+ const combined = (r.stdout || "") + (r.stderr || "");
212
+ const warnCount = (
213
+ combined.match(/^WARN\b/gm) || []
214
+ ).length + (
215
+ combined.match(/\[warn\]/g) || []
216
+ ).length;
217
+ if (r.status === 0) {
218
+ return { status: "passed", durationMs, warnCount };
219
+ }
220
+ // Audit G F12 — gates may declare informationalMaxExitCode to distinguish
221
+ // "soft signal" (exit codes 0..N) from "crash" (> N). Default behaviour
222
+ // for an informational gate without that field stays the same.
223
+ if (gate.informational) {
224
+ const ceil = typeof gate.informationalMaxExitCode === "number"
225
+ ? gate.informationalMaxExitCode
226
+ : Infinity;
227
+ if (r.status !== null && r.status > ceil) {
185
228
  return {
186
- status: "informational",
187
- exitCode: e.status ?? null,
188
- message: e.message,
229
+ status: "failed",
230
+ exitCode: r.status,
231
+ message: `informational gate crashed (exit ${r.status} > informationalMaxExitCode=${ceil})`,
232
+ durationMs,
233
+ warnCount,
189
234
  };
190
235
  }
191
236
  return {
192
- status: "failed",
193
- exitCode: e.status ?? null,
194
- message: e.message,
237
+ status: "informational",
238
+ exitCode: r.status ?? null,
239
+ durationMs,
240
+ warnCount,
195
241
  };
196
242
  }
243
+ return {
244
+ status: "failed",
245
+ exitCode: r.status ?? null,
246
+ message: r.error ? r.error.message : `exit ${r.status}`,
247
+ durationMs,
248
+ warnCount,
249
+ };
250
+ }
251
+
252
+ function fmtMs(ms) {
253
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
254
+ return `${ms} ms`;
197
255
  }
198
256
 
199
257
  function main() {
@@ -202,17 +260,19 @@ function main() {
202
260
  process.stdout.write(`\n=== ${gate.name} ===\n`);
203
261
  const outcome = runGate(gate);
204
262
  results.push({ gate, outcome });
263
+ const timing = fmtMs(outcome.durationMs);
264
+ const timingSuffix = timing ? ` (${timing})` : "";
205
265
  if (outcome.status === "skipped") {
206
266
  process.stdout.write(` ⊘ skipped — ${outcome.reason}\n`);
207
267
  } else if (outcome.status === "passed") {
208
- process.stdout.write(` ✓ passed\n`);
268
+ process.stdout.write(` ✓ passed${timingSuffix}\n`);
209
269
  } else if (outcome.status === "informational") {
210
270
  process.stdout.write(
211
- ` ℹ informational (exit ${outcome.exitCode ?? "?"}) — not failing the run\n`
271
+ ` ℹ informational (exit ${outcome.exitCode ?? "?"})${timingSuffix} — not failing the run\n`
212
272
  );
213
273
  } else {
214
274
  process.stdout.write(
215
- ` ✗ failed (exit ${outcome.exitCode ?? "?"}): ${outcome.message}\n`
275
+ ` ✗ failed (exit ${outcome.exitCode ?? "?"})${timingSuffix}: ${outcome.message}\n`
216
276
  );
217
277
  }
218
278
  }
@@ -232,8 +292,18 @@ function main() {
232
292
  : outcome.status === "informational"
233
293
  ? "ℹ"
234
294
  : "✗";
295
+ const timing = fmtMs(outcome.durationMs);
296
+ const timingSuffix = timing ? ` (${timing})` : "";
297
+ // F21 — surface WARN counts so a gate that "passed (3 warnings)" is
298
+ // distinguishable from one that passed cleanly. Pre-fix, warnings
299
+ // printed by individual gates (validate-cve-catalog, lint-skills,
300
+ // validate-playbooks) scrolled past invisible in the summary.
301
+ const warnSuffix =
302
+ outcome.warnCount && outcome.warnCount > 0
303
+ ? ` (${outcome.warnCount} warning${outcome.warnCount === 1 ? "" : "s"})`
304
+ : "";
235
305
  process.stdout.write(
236
- ` ${icon} ${gate.name.padEnd(widest)} ${outcome.status}\n`
306
+ ` ${icon} ${gate.name.padEnd(widest)} ${outcome.status}${warnSuffix}${timingSuffix}\n`
237
307
  );
238
308
  }
239
309
 
@@ -11,10 +11,22 @@
11
11
  * blindly — read the breaking-change list first. A breaking change is
12
12
  * a surface narrowing every downstream consumer needs to know about.
13
13
  *
14
+ * Audit G F5 — commitOnly mode. Pass `--commit-only` (or set the env
15
+ * EXCEPTD_SNAPSHOT_AUDIT_ACK=1) to acknowledge that the operator
16
+ * deliberately wants to overwrite the committed snapshot. When neither
17
+ * flag nor env is set AND the snapshot would actually change, the
18
+ * script refuses and emits a structured diff hint. This stops an
19
+ * accidental `npm run refresh-snapshot` (run as muscle-memory while
20
+ * triaging a failing gate) from masking a real breaking change.
21
+ *
14
22
  * Usage:
15
- * node scripts/refresh-manifest-snapshot.js
16
- * git add manifest-snapshot.json
17
- * git commit -m "refresh manifest snapshot: <what changed>"
23
+ * node scripts/refresh-manifest-snapshot.js # dry-shows the diff
24
+ * EXCEPTD_SNAPSHOT_AUDIT_ACK=1 \
25
+ * node scripts/refresh-manifest-snapshot.js # writes the new snapshot
26
+ * node scripts/refresh-manifest-snapshot.js --commit-only # same thing, on argv
27
+ *
28
+ * The flag is documented in scripts/predeploy.js so contributors see it
29
+ * the moment the snapshot gate fails.
18
30
  */
19
31
 
20
32
  const fs = require("fs");
@@ -50,8 +62,47 @@ function captureSurface(manifest) {
50
62
 
51
63
  const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
52
64
  const snapshot = captureSurface(manifest);
65
+ const newJson = JSON.stringify(snapshot, null, 2) + "\n";
66
+
67
+ // F5 — refuse to overwrite an existing snapshot unless the operator
68
+ // has explicitly acknowledged the rewrite (env or --commit-only flag).
69
+ const argv = process.argv.slice(2);
70
+ const commitOnly =
71
+ argv.includes("--commit-only") ||
72
+ process.env.EXCEPTD_SNAPSHOT_AUDIT_ACK === "1";
53
73
 
54
- fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
74
+ if (fs.existsSync(SNAPSHOT_PATH) && !commitOnly) {
75
+ const current = fs.readFileSync(SNAPSHOT_PATH, "utf8");
76
+ // Normalise the _generated_at timestamp for comparison — that field
77
+ // changes every run and shouldn't trigger the guard.
78
+ const stripGenerated = (s) => s.replace(
79
+ /"_generated_at":\s*"[^"]+",?\s*\n?/, ""
80
+ );
81
+ if (stripGenerated(current) === stripGenerated(newJson)) {
82
+ console.log("[refresh-manifest-snapshot] snapshot unchanged — nothing to do.");
83
+ process.exit(0);
84
+ }
85
+ process.stderr.write(
86
+ "[refresh-manifest-snapshot] REFUSING to overwrite manifest-snapshot.json — " +
87
+ "the captured surface differs from the committed snapshot.\n" +
88
+ " Re-run with `--commit-only` (or EXCEPTD_SNAPSHOT_AUDIT_ACK=1) to confirm " +
89
+ "the rewrite is intentional. The check-manifest-snapshot.js gate exists to " +
90
+ "force a deliberate decision about removed skills / triggers / refs before " +
91
+ "the baseline is rewritten.\n"
92
+ );
93
+ process.exit(1);
94
+ }
95
+
96
+ fs.writeFileSync(SNAPSHOT_PATH, newJson, "utf8");
55
97
 
56
98
  console.log(`[refresh-manifest-snapshot] wrote ${snapshot.skill_count} skills to manifest-snapshot.json`);
57
99
  console.log("[refresh-manifest-snapshot] commit this file alongside the surface change.");
100
+
101
+ // Audit G F23 — write a tracked SHA-256 of the snapshot so the
102
+ // check-manifest-snapshot.js gate can verify integrity (no hand edits
103
+ // after refresh).
104
+ const crypto = require("crypto");
105
+ const snapshotSha = crypto.createHash("sha256").update(newJson).digest("hex");
106
+ const snapshotShaPath = path.join(ROOT, "manifest-snapshot.sha256");
107
+ fs.writeFileSync(snapshotShaPath, snapshotSha + " manifest-snapshot.json\n", "utf8");
108
+ console.log(`[refresh-manifest-snapshot] wrote integrity hash to manifest-snapshot.sha256`);