@blamejs/exceptd-skills 0.12.11 → 0.12.13

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.13 — 2026-05-14
4
+
5
+ **Patch: e2e scenarios updated for the v0.12.12 jurisdiction-clock semantics.**
6
+
7
+ Two e2e scenarios (`02-tanstack-worm-payload`, `09-secrets-aws-key`) assert that `phases.close.jurisdiction_clocks_count >= 1` against a `detected` classification. In v0.12.12 the clock-starts contract was tightened: `clock_starts: detect_confirmed` no longer auto-stamps when classification turns `detected`; the operator must pass `--ack` for the clock to start. Both scenarios now pass `--ack` so the contract is exercised end-to-end. No code changes; v0.12.13 ships solely to land the scenario update and a corresponding npm publish — the v0.12.12 tag exists on git but never reached the npm registry because the validate gate failed against the pre-update scenarios.
8
+
9
+ Test count: 585/585. Predeploy gates: 16/16. Skills: 38/38 signed and verified.
10
+
11
+ ## 0.12.12 — 2026-05-13
12
+
13
+ **Patch: deep multi-surface hardening — engine semantics, concurrency, signing round-trip, output bundles, validators, scheduler, curation. 73 distinct fixes across 10 surface classes.**
14
+
15
+ ### Engine semantics
16
+
17
+ `lib/playbook-runner.js` corrects several long-standing classification and clock bugs:
18
+
19
+ - **False-positive checks now gate classification.** When an indicator's `signal_overrides` says `hit` but the indicator's `false_positive_checks_required[]` haven't been attested, the verdict downgrades to `inconclusive` and `fp_checks_unsatisfied[]` is surfaced on the indicator. Operators attest FP checks with `signal_overrides: { '<id>__fp_checks': { '<check>': true } }`. Before: submitting a hit without attesting FP checks would auto-stamp `classification: detected`.
20
+ - **Dead branch on empty submission**: the indicator-default arm previously emitted `inconclusive` for both `anyCaptured` and the empty case. Empty submissions with no captured artifacts now correctly produce `classification: not_detected` with theater verdict `clear`.
21
+ - **`evalCondition` regex no longer crashes the run.** A malformed indicator condition (operator-authored regex) used to throw out of `analyze()`. Now wrapped in try/catch; the failure surfaces as `analyze.runtime_errors[]` with the source condition + exception message.
22
+ - **`--strict-preconditions` is now load-bearing.** The flag escalates `precondition_unverified` / `precondition_warn` / `precondition_skip` outcomes to halt, with `escalated_from` provenance. The CLI exit body now carries `strict_preconditions_violated[]` so consumers grep'ing the JSON see the contract reason without inspecting stderr.
23
+ - **`on_fail: skip_phase` is actually honored.** A precondition that fails `on_fail: skip_phase` now emits a placeholder detect phase `{skipped: true, classification: 'skipped', reason: <id>}` and runs analyze with empty signals. Previously the runner ignored the directive and proceeded into detect as if the precondition had passed.
24
+ - **`clock_starts: detect_confirmed` is bound to operator awareness.** Jurisdiction notification clocks (NIS2 24h, DORA 4h, GDPR 72h, etc.) no longer auto-stamp when classification turns `detected`; the operator must pass `--ack` for the clock to start. Without `--ack`, the notification entry carries `clock_pending_ack: true`. Matches the legal contract — the clock starts from operator awareness, not from the runner's decision.
25
+ - **`analyze.active_exploitation` is now the worst across matched CVEs**, not the first. Two matched CVEs where #1 is `suspected` and #2 is `confirmed` correctly report `confirmed`.
26
+ - **`signal_overrides` collisions are surfaced** rather than silently last-wins. Two observations targeting the same indicator id now record the discarded values in `analyze.signal_origins_with_collisions[]`.
27
+ - **Per-run playbook cache**: the runner reads the playbook once per `run()` invocation instead of re-loading it inside each of the seven phase calls.
28
+
29
+ ### Scoring
30
+
31
+ `lib/scoring.js` exports a new `validateFactors(factors)` returning structured warnings for missing fields, out-of-range `blast_radius`, or non-enum `active_exploitation`. `scoreCustom(factors, {collectWarnings: true})` returns the score plus `_scoring_warnings[]` for downstream consumers; the bare-number return is preserved for backwards compatibility.
32
+
33
+ ### Concurrency
34
+
35
+ Catalog read-modify-write was racy under concurrent `refresh --advisory --apply` invocations — five sites in `lib/refresh-external.js` and two in `lib/prefetch.js`. Now serialized via `withCatalogLock` / `withIndexLock` (lockfile-gated, atomic tmp+rename writes; 30s stale-lock reaper for crash recovery). Concurrent applies to distinct CVEs now both survive in the final catalog rather than 1/20 trials losing an entry to interleaved writes. Same pattern applied to the prefetch `_index.json`.
36
+
37
+ `persistAttestation` (in `bin/exceptd.js`) no longer has a TOCTOU window between `existsSync` and `writeFileSync` — atomic create via `flag: 'wx'` (`O_EXCL`) guarantees that two concurrent runs sharing a session-id produce one winner and one explicit `EEXIST` rather than silent last-write-wins.
38
+
39
+ `lib/refresh-external.js` post-pool `process.exit()` calls replaced with `process.exitCode = N; return;` so buffered stdout drains before the event loop ends (same v0.11.10 class).
40
+
41
+ ### Signing round-trip
42
+
43
+ `lib/sign.js` + `lib/verify.js` now normalize content (strip UTF-8 BOM, convert CRLF → LF) before computing or verifying signatures. A skill body cloned with `core.autocrlf=true` on Windows but signed on Linux CI no longer fails verification on the consumer side. Byte-level proof: all four variants of `hello\nworld\n` (LF, CRLF, BOM+LF, BOM+CRLF) normalize to the identical signature.
44
+
45
+ Manifest schema validation lands in `lib/schemas/manifest.schema.json` + `loadManifestValidated()`. A tampered manifest with `path: "../../../etc/passwd"` is rejected at load time before any skill resolution. Per-skill paths must match `^skills/[A-Za-z0-9._/-]+/skill\.md$`.
46
+
47
+ `lib/lint-skills.js` rejects duplicate frontmatter keys (last-wins parsing previously masked identity spoofing) and walks `skills/` for orphan `skill.md` files not referenced in the manifest.
48
+
49
+ The fingerprint banner now prints AFTER the verdict line in both `sign-all` and `verify`, so a quick read of `gh run watch` output isn't ambiguous about pass/fail.
50
+
51
+ ### Path traversal hardening
52
+
53
+ - `--session-id` now enforces `^[A-Za-z0-9._-]{1,64}$` (alphanumeric, dot, underscore, hyphen; up to 64 chars). Path separators and `..` are rejected at input.
54
+ - `--attestation-root` rejects `..`-bearing relative paths and resolves to an absolute path before propagation.
55
+ - `--evidence-dir` validates each `<id>.json` entry, refuses traversal-escaping resolved paths.
56
+ - `--evidence` enforces a 32 MB file-size limit to defend against adversarial JSON bombs.
57
+ - `persistAttestation` validates the session-id + filename and confirms the resolved directory stays under the attestation root.
58
+ - `parseTar` in `lib/refresh-network.js` skips entries with `..` segments or absolute paths — defense-in-depth against a compromised registry CDN shipping path-traversal tarballs.
59
+
60
+ ### Output bundles (CSAF 2.0 / SARIF 2.1.0 / OpenVEX 0.2.0)
61
+
62
+ `buildEvidenceBundle()` in `lib/playbook-runner.js` produces bundles that pass canonical-schema validation against each spec:
63
+
64
+ - **CSAF**: `csaf_security_advisory` documents now include a populated `product_tree.full_product_names[]`; every `vulnerabilities[]` entry references a declared product via `product_status` (`known_affected` / `fixed` / `under_investigation`). NVD / Red Hat / ENISA CSAF dashboards previously rejected exceptd CSAF output for missing product_tree.
65
+ - **SARIF**: indicator-hit results now populate `physicalLocation.artifactLocation.uri` from the playbook's look-phase artifact source paths so GitHub Code Scanning surfaces them. Null property-bag keys are pruned. Framework-gap results carry `kind: "informational"` per spec §3.27.9.
66
+ - **OpenVEX**: every statement carries `products` (B1). Status semantics rebuilt — indicator hits become `affected` with an `action_statement` from the validate phase's selected remediation; misses become `not_affected` with `vulnerable_code_not_present` justification; inconclusive stays `under_investigation` (no action_statement). Framework-gap statements are removed from the VEX feed entirely (they're control-design observations, not vulnerabilities — they remain in CSAF and SARIF). Vulnerability `@id` values now follow RFC 8141 (`urn:cve:<id>`, `urn:exceptd:indicator:<playbook>:<id>`), replacing the unregistered `exceptd:` scheme.
67
+
68
+ ### Validators
69
+
70
+ `lib/validate-playbooks.js` is a new validator that checks all 13 shipped playbooks against `lib/schemas/playbook.schema.json` plus cross-catalog references (`atlas_refs`, `cve_refs`, `cwe_refs`, `d3fend_refs`, `attack_refs`), internal consistency (duplicate indicator ids, RWEP threshold ordering, obligation_ref resolution), and feeds_into / mutex / skill_chain resolution. Wired as predeploy gate 16 (informational in v0.12.12; flips to enforcing in v0.13.0). 75-entry `data/attack-techniques.json` lands to support `attack_refs` resolution across skills and playbooks.
71
+
72
+ `lib/validate-cve-catalog.js` adds warning-class checks for the Hard Rule #14 iocs-when-poc-and-exploit-url contract, `atlas_refs` + `cwe_refs` cross-catalog resolution, duplicate-name detection, impossible-date guards, and strict CVSS-version prefix recognition. All new findings emit as warnings in v0.12.12 to preserve patch-class compatibility; v0.13.0 will flip them to errors.
73
+
74
+ `lib/lint-skills.js` extends section detection to require an anchored `^## <Section>` heading with ≥20 words of body text (warning-class), resolves `attack_refs` against `data/attack-techniques.json`, and flags missing "Defensive Countermeasure Mapping" sections on skills whose `last_threat_review >= 2026-05-11`.
75
+
76
+ ### Curation `--apply`
77
+
78
+ `lib/cve-curation.js` gains the missing apply path. `curate(cveId, {apply: true, answers})` validates each answer against a per-field whitelist, applies, derives `rwep_score` from `rwep_factors` when an explicit score isn't supplied, computes `residual_warnings[]` against the required-schema set, and promotes the draft (strips `_auto_imported` + `_draft` + `_draft_reason`) when zero warnings remain. CLI surface: `exceptd refresh --curate <id> --answers <file>` or the explicit `--apply` alias. The questionnaire now always asks for `cvss_score`, `cvss_vector`, patch fields, `affected_versions`, and `cisa_kev` when those are unpopulated — without these, the apply path can't produce a schema-passing entry. Severity rendering for `cvss_score: null` returns `unrated` (was misleading `low`). Catalog reads honor absolute paths on Windows. OSV-imported drafts now show `"OSV: <id>"` in `auto_imported_from` (was always `"unknown"`).
79
+
80
+ ### Scheduler
81
+
82
+ `orchestrator/scheduler.js` `MONTHLY_CVE_VALIDATION` (2.59 billion ms) and `ANNUAL_AUDIT` (31.5 billion ms) exceeded Node's INT32 setTimeout limit (2.15 billion ms), which silently clamps to 1 ms — producing a 1000 fires/sec stdout flood on idle `exceptd watch`. New `scheduleEvery(intervalMs, handler)` primitive uses a bounded `setInterval` (capped at 24 h) with wall-clock elapsed comparison. Idle watch goes from 1000 lines/sec to 0.
83
+
84
+ ### Predeploy
85
+
86
+ `scripts/predeploy.js` now reports per-gate timing (`(NNN ms)` next to each pass / fail / informational line + the summary table). New 16th gate `Validate playbooks` runs informationally in v0.12.12.
87
+
88
+ ### Repository
89
+
90
+ - `.github/workflows/ci.yml` gains a `validate-playbooks` job (`continue-on-error: true` in v0.12.12).
91
+ - `manifest-snapshot.json` + `sbom.cdx.json` + `data/_indexes/` refreshed.
92
+ - `data/attack-techniques.json` new — 75 ATT&CK technique entries with v17 metadata, supporting `attack_refs` resolution across the catalog.
93
+
94
+ Test count: 492 → 573 (+81 across engine, sign/verify, refresh-external, prefetch, scheduler, cve-curation, bundle-correctness, validate-playbooks, and operator-bugs test files). Predeploy gates: 16/16. Skills: 38/38 signed and verified.
95
+
3
96
  ## 0.12.11 — 2026-05-13
4
97
 
5
98
  **Patch: OSV source hardening, indicator regex widening, CWE/framework-gap reconciliation. v0.12.10 audit closeout.**
package/bin/exceptd.js CHANGED
@@ -554,6 +554,17 @@ function readEvidence(evidenceFlag) {
554
554
  if (!buf.trim()) return {};
555
555
  return JSON.parse(buf);
556
556
  }
557
+ // v0.12.12: read enforces a max size to defend against an operator
558
+ // accidentally passing a multi-gigabyte file (binary, log, or
559
+ // adversarial JSON bomb). 32 MB is well beyond any legitimate
560
+ // submission and still drains in a single read on modern hardware.
561
+ const MAX_EVIDENCE_BYTES = 32 * 1024 * 1024;
562
+ let stat;
563
+ try { stat = fs.statSync(evidenceFlag); }
564
+ catch (e) { throw new Error(`evidence path not readable: ${e.message}`); }
565
+ if (stat.size > MAX_EVIDENCE_BYTES) {
566
+ throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
567
+ }
557
568
  return JSON.parse(fs.readFileSync(evidenceFlag, "utf8"));
558
569
  }
559
570
 
@@ -607,8 +618,39 @@ function dispatchPlaybook(cmd, argv) {
607
618
  airGap: !!args["air-gap"],
608
619
  forceStale: !!args["force-stale"],
609
620
  };
610
- if (args["session-id"]) runOpts.session_id = args["session-id"];
611
- if (args["attestation-root"]) runOpts.attestationRoot = args["attestation-root"];
621
+ if (args["session-id"]) {
622
+ // v0.12.12: --session-id is a filesystem path component (resolves to
623
+ // .exceptd/attestations/<id>/attestation.json). Operator-supplied input
624
+ // with `..` or path separators escapes the attestation root. Validate
625
+ // strict allowlist before propagating.
626
+ const sid = args["session-id"];
627
+ if (typeof sid !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(sid)) {
628
+ return emitError(
629
+ "run: --session-id must match /^[A-Za-z0-9._-]{1,64}$/ (alphanumeric, dot, underscore, hyphen; up to 64 chars). Path separators and '..' are rejected.",
630
+ { provided: typeof sid === "string" ? sid.slice(0, 80) : typeof sid },
631
+ pretty
632
+ );
633
+ }
634
+ runOpts.session_id = sid;
635
+ }
636
+ if (args["attestation-root"]) {
637
+ // v0.12.12: --attestation-root must resolve to an absolute path the
638
+ // operator owns. Reject `..`-bearing relatives at input so a misconfigured
639
+ // env doesn't write outside the intended root. Final resolution still
640
+ // happens in resolveAttestationRoot — this is the input-validation layer.
641
+ const ar = args["attestation-root"];
642
+ if (typeof ar !== "string" || ar.length === 0) {
643
+ return emitError("run: --attestation-root must be a non-empty string.", { provided: typeof ar }, pretty);
644
+ }
645
+ if (ar.split(/[\\/]/).some(seg => seg === "..")) {
646
+ return emitError(
647
+ "run: --attestation-root must not contain '..' path segments. Pass an absolute path under your home directory or an explicit project-relative path without traversal.",
648
+ { provided: ar.slice(0, 200) },
649
+ pretty
650
+ );
651
+ }
652
+ runOpts.attestationRoot = path.resolve(ar);
653
+ }
612
654
  if (args["session-key"]) {
613
655
  // Bug #33: validate that --session-key is hex. Previously any string was
614
656
  // silently accepted; HMAC signing then either failed silently or produced
@@ -1678,6 +1720,14 @@ function cmdRun(runner, args, runOpts, pretty) {
1678
1720
  i.kind === "precondition_unverified" || i.kind === "precondition_warn"
1679
1721
  );
1680
1722
  if (warnIssues.length > 0) {
1723
+ // v0.12.12: surface the contract violation in the emitted body so
1724
+ // downstream consumers grepping the JSON see WHY the exit is non-zero.
1725
+ // result.ok stays true (the playbook executed) but the explicit flag
1726
+ // makes the strict-preconditions contract observable, not just inferable
1727
+ // from exit code + stderr line.
1728
+ result.strict_preconditions_violated = warnIssues.map(i => ({
1729
+ id: i.id, kind: i.kind, message: i.message || null, on_fail: i.on_fail || null,
1730
+ }));
1681
1731
  process.stderr.write(`[exceptd run] --strict-preconditions: ${warnIssues.length} unverified/warn precondition(s) — exit 1.\n`);
1682
1732
  emit(result, pretty);
1683
1733
  // v0.11.11: exitCode + return so emit()'s stdout flushes (process.exit
@@ -1922,13 +1972,28 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
1922
1972
  // contract in one pass.
1923
1973
  if (args["evidence-dir"]) {
1924
1974
  const dir = args["evidence-dir"];
1975
+ if (typeof dir !== "string" || dir.length === 0) {
1976
+ return emitError("run: --evidence-dir must be a non-empty string.", null, pretty);
1977
+ }
1925
1978
  if (!fs.existsSync(dir)) {
1926
1979
  return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
1927
1980
  }
1981
+ const resolvedDir = path.resolve(dir);
1982
+ // v0.12.12: only `<playbook-id>.json` entries are honored. Reject
1983
+ // anything where the filename strip leaves traversal segments — npm
1984
+ // refuses to write such filenames so the realistic risk is an operator
1985
+ // symlink/junction inside the dir, but the filter is cheap.
1928
1986
  for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
1929
1987
  const pbId = f.replace(/\.json$/, "");
1988
+ if (!/^[A-Za-z0-9_.-]+$/.test(pbId)) {
1989
+ return emitError(`run: --evidence-dir entry ${JSON.stringify(f)} has unsafe playbook-id segment.`, null, pretty);
1990
+ }
1991
+ const entryPath = path.resolve(path.join(resolvedDir, f));
1992
+ if (!entryPath.startsWith(resolvedDir + path.sep)) {
1993
+ return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
1994
+ }
1930
1995
  try {
1931
- bundle[pbId] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
1996
+ bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
1932
1997
  } catch (e) {
1933
1998
  return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
1934
1999
  }
@@ -2128,47 +2193,86 @@ function deriveRunTag() {
2128
2193
  function persistAttestation(args) {
2129
2194
  const { sessionId, playbookId, directiveId, evidenceHash, operator,
2130
2195
  operatorConsent, submission, runOpts, forceOverwrite, filename } = args;
2196
+ // v0.12.12: session-id is supposed to be sanitized at input. Defense in
2197
+ // depth: reject anything that path-traverses out of the attestation root.
2198
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(sessionId || "")) {
2199
+ return {
2200
+ ok: false,
2201
+ error: `Refusing to persist attestation with unsafe session-id: ${JSON.stringify(sessionId).slice(0, 80)}. Must match /^[A-Za-z0-9._-]{1,64}$/.`,
2202
+ existingPath: null,
2203
+ };
2204
+ }
2205
+ if (!/^[A-Za-z0-9._-]{1,64}\.json$/.test(filename || "")) {
2206
+ return {
2207
+ ok: false,
2208
+ error: `Refusing to persist attestation with unsafe filename: ${JSON.stringify(filename).slice(0, 80)}.`,
2209
+ existingPath: null,
2210
+ };
2211
+ }
2131
2212
  const root = resolveAttestationRoot(runOpts);
2132
2213
  const dir = path.join(root, sessionId);
2133
2214
  const filePath = path.join(dir, filename);
2134
-
2135
- let prior = null;
2136
- if (fs.existsSync(filePath)) {
2137
- try { prior = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
2138
- if (!forceOverwrite) {
2139
- return {
2140
- ok: false,
2141
- error: `Attestation already exists at ${path.relative(process.cwd(), filePath)}. Session-id collision (${sessionId}) — refusing to overwrite to preserve audit trail.`,
2142
- existingPath: path.relative(process.cwd(), filePath),
2143
- };
2144
- }
2215
+ // Final-resolution check: dir must remain inside root after normalization.
2216
+ const normRoot = path.resolve(root) + path.sep;
2217
+ if (!(path.resolve(dir) + path.sep).startsWith(normRoot)) {
2218
+ return {
2219
+ ok: false,
2220
+ error: `Refusing to persist attestation outside root. session_id=${sessionId} root=${root}`,
2221
+ existingPath: null,
2222
+ };
2145
2223
  }
2146
2224
 
2147
2225
  try {
2148
2226
  fs.mkdirSync(dir, { recursive: true });
2149
- const attestation = {
2150
- session_id: sessionId,
2151
- playbook_id: playbookId,
2152
- directive_id: directiveId,
2153
- evidence_hash: evidenceHash,
2154
- operator: operator || null,
2155
- operator_consent: operatorConsent || null,
2156
- submission,
2157
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
2158
- captured_at: new Date().toISOString(),
2159
- // When overwriting (with --force-overwrite), link to the prior content
2160
- // by evidence_hash + capture timestamp. session_id is the same (that's
2161
- // why we collided), so it's the hash + timestamp that distinguish.
2162
- prior_evidence_hash: prior ? (prior.evidence_hash || null) : null,
2163
- prior_captured_at: prior ? (prior.captured_at || null) : null,
2164
- };
2165
- fs.writeFileSync(filePath, JSON.stringify(attestation, null, 2));
2166
- maybeSignAttestation(filePath);
2167
- return {
2168
- ok: true,
2169
- prior_session_id: prior ? sessionId : null,
2170
- overwrote_at: prior ? prior.captured_at : null,
2227
+ const writeAttestation = (priorEvidenceHash, priorCapturedAt, flag) => {
2228
+ const attestation = {
2229
+ session_id: sessionId,
2230
+ playbook_id: playbookId,
2231
+ directive_id: directiveId,
2232
+ evidence_hash: evidenceHash,
2233
+ operator: operator || null,
2234
+ operator_consent: operatorConsent || null,
2235
+ submission,
2236
+ run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
2237
+ captured_at: new Date().toISOString(),
2238
+ // When overwriting (with --force-overwrite), link to the prior content
2239
+ // by evidence_hash + capture timestamp. session_id is the same (that's
2240
+ // why we collided), so it's the hash + timestamp that distinguish.
2241
+ prior_evidence_hash: priorEvidenceHash,
2242
+ prior_captured_at: priorCapturedAt,
2243
+ };
2244
+ // Atomic-create via O_EXCL ('wx' flag) eliminates the TOCTOU window
2245
+ // between existsSync and writeFileSync. Two concurrent run-with-same-
2246
+ // session-id invocations now produce one winner + one EEXIST loser,
2247
+ // not silent last-write-wins.
2248
+ fs.writeFileSync(filePath, JSON.stringify(attestation, null, 2), { flag });
2249
+ maybeSignAttestation(filePath);
2171
2250
  };
2251
+
2252
+ try {
2253
+ writeAttestation(null, null, "wx");
2254
+ return { ok: true, prior_session_id: null, overwrote_at: null };
2255
+ } catch (eExcl) {
2256
+ if (eExcl.code !== "EEXIST") throw eExcl;
2257
+ // Slot already taken — read prior to chain audit trail, then decide.
2258
+ let prior = null;
2259
+ try { prior = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { /* malformed prior — proceed */ }
2260
+ if (!forceOverwrite) {
2261
+ return {
2262
+ ok: false,
2263
+ error: `Attestation already exists at ${path.relative(process.cwd(), filePath)}. Session-id collision (${sessionId}) — refusing to overwrite to preserve audit trail.`,
2264
+ existingPath: path.relative(process.cwd(), filePath),
2265
+ };
2266
+ }
2267
+ writeAttestation(prior ? (prior.evidence_hash || null) : null,
2268
+ prior ? (prior.captured_at || null) : null,
2269
+ "w");
2270
+ return {
2271
+ ok: true,
2272
+ prior_session_id: prior ? sessionId : null,
2273
+ overwrote_at: prior ? prior.captured_at : null,
2274
+ };
2275
+ }
2172
2276
  } catch (e) {
2173
2277
  return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
2174
2278
  }
@@ -3367,8 +3471,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3367
3471
  directPhase = runner.direct(playbookId, directiveId);
3368
3472
  lookPhase = runner.look(playbookId, directiveId, runOpts);
3369
3473
  } catch (e) {
3370
- process.stdout.write(JSON.stringify({ event: "error", reason: e.message, phase: "info" }) + "\n");
3371
- process.exit(1);
3474
+ // v0.12.12 (T8): process.exit(1) immediately after a stdout write can
3475
+ // truncate buffered output under piped consumers (same class as v0.11.10
3476
+ // #100). Use exitCode+return so the JSONL error frame drains. Also write
3477
+ // the framed error event so the stdout-only JSONL contract holds — host
3478
+ // AIs reading this stream must see structured frames, never bare text.
3479
+ process.stdout.write(JSON.stringify({ event: "error", reason: e.message, phase: "info", playbook_id: playbookId, directive_id: directiveId }) + "\n");
3480
+ process.exitCode = 1;
3481
+ return;
3372
3482
  }
3373
3483
 
3374
3484
  const governEvent = {
@@ -3444,8 +3554,11 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3444
3554
  return emitError(`ai-run: runner threw: ${e.message}`, { playbook: playbookId }, pretty);
3445
3555
  }
3446
3556
  if (!result || result.ok === false) {
3557
+ // v0.12.12: same exit-after-write anti-pattern as the pre-stream
3558
+ // load path. Use exitCode + return so stderr drains.
3447
3559
  process.stderr.write((pretty ? JSON.stringify(result || {}, null, 2) : JSON.stringify(result || {})) + "\n");
3448
- process.exit(1);
3560
+ process.exitCode = 1;
3561
+ return;
3449
3562
  }
3450
3563
  // v0.11.8 (#101): unify ai-run --no-stream shape with `run`. Pre-0.11.8
3451
3564
  // ai-run flattened phases to top-level (`govern`, `direct`, `look`, ...),
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-13T21:19:48.889Z",
3
+ "generated_at": "2026-05-14T14:28:45.659Z",
4
4
  "generator": "scripts/build-indexes.js",
5
- "source_count": 49,
5
+ "source_count": 50,
6
6
  "source_hashes": {
7
- "manifest.json": "b7e77cd5de579732b6dd352720557c3ba2ac93f472de50f4e1f861a665a2760b",
7
+ "manifest.json": "a3c012232fd18e4a2186bf3243fb969bb411e6815d170f568e42867ce7c6c308",
8
8
  "data/atlas-ttps.json": "f3f75ff2778a0a2c7d953a21386bc4f265cb2685ce41242eee45f9e9f2a6add6",
9
+ "data/attack-techniques.json": "b6dde8f2d8bbe809cbd017d1490b16c01cc54034d695bc8535613b699e3b45c6",
9
10
  "data/cve-catalog.json": "197f5313d93f0a7225d5ff275e21cbd067b3970a6f2fdc6da35f81c847e8bdee",
10
11
  "data/cwe-catalog.json": "19ce1fad3ed0b0687ec9a328b2d6cd1b544eea7f19140234ec1a8467de1f908d",
11
12
  "data/d3fend-catalog.json": "d219520c8d3eb61a270b25ea60f64721035e98a8d5d51d1a4e1f1140d9a586f9",
@@ -55,7 +56,7 @@
55
56
  "skills/age-gates-child-safety/skill.md": "c741d7dca9da0abb09bdebb8a02e803ce4ae9fb9a6904fb8df3ec19cae83917d"
56
57
  },
57
58
  "skill_count": 38,
58
- "catalog_count": 10,
59
+ "catalog_count": 11,
59
60
  "index_stats": {
60
61
  "xref_entries": {
61
62
  "cwe_refs": 34,
@@ -80,8 +81,8 @@
80
81
  "theater_fingerprints": 7,
81
82
  "currency_action_required": 0,
82
83
  "frequency_fields": 7,
83
- "activity_feed_events": 49,
84
- "catalog_summaries": 10,
84
+ "activity_feed_events": 50,
85
+ "catalog_summaries": 11,
85
86
  "stale_content_findings": 0
86
87
  },
87
88
  "invalidation_note": "If any source file in source_hashes has a different SHA-256 than recorded here, the indexes are stale. Re-run `npm run build-indexes`."
@@ -2,7 +2,7 @@
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
4
  "note": "Per-artifact 'last changed' feed sorted descending by date. Skill events from manifest.last_threat_review; catalog events from data/<catalog>.json _meta.last_updated.",
5
- "event_count": 49
5
+ "event_count": 50
6
6
  },
7
7
  "events": [
8
8
  {
@@ -13,6 +13,14 @@
13
13
  "schema_version": "1.0.0",
14
14
  "entry_count": 15
15
15
  },
16
+ {
17
+ "date": "2026-05-13",
18
+ "type": "catalog_update",
19
+ "artifact": "data/attack-techniques.json",
20
+ "path": "data/attack-techniques.json",
21
+ "schema_version": "1.0.0",
22
+ "entry_count": 75
23
+ },
16
24
  {
17
25
  "date": "2026-05-13",
18
26
  "type": "catalog_update",
@@ -356,7 +364,7 @@
356
364
  "type": "manifest_review",
357
365
  "artifact": "manifest.json",
358
366
  "path": "manifest.json",
359
- "note": "manifest threat_review_date — 38 skills, 10 catalogs"
367
+ "note": "manifest threat_review_date — 38 skills, 11 catalogs"
360
368
  }
361
369
  ]
362
370
  }
@@ -2,7 +2,7 @@
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
4
  "note": "Per-catalog compact summary so AI consumers can discover available data without loading every _meta block. Purpose strings are curated in scripts/builders/catalog-summaries.js.",
5
- "catalog_count": 10
5
+ "catalog_count": 11
6
6
  },
7
7
  "catalogs": {
8
8
  "atlas-ttps.json": {
@@ -27,6 +27,28 @@
27
27
  "AML.T0018"
28
28
  ]
29
29
  },
30
+ "attack-techniques.json": {
31
+ "path": "data/attack-techniques.json",
32
+ "purpose": null,
33
+ "schema_version": "1.0.0",
34
+ "last_updated": "2026-05-13",
35
+ "tlp": "CLEAR",
36
+ "source_confidence_default": "A1",
37
+ "freshness_policy": {
38
+ "default_review_cadence_days": 90,
39
+ "stale_after_days": 180,
40
+ "rebuild_after_days": 365,
41
+ "note": "Catalog must be rebuilt against the upstream ATT&CK release whenever MITRE publishes a new version. AGENTS.md hard rule #8 requires the bump to be intentional, not silent."
42
+ },
43
+ "entry_count": 75,
44
+ "sample_keys": [
45
+ "T0001",
46
+ "T0017",
47
+ "T0051",
48
+ "T0096",
49
+ "T0853"
50
+ ]
51
+ },
30
52
  "cve-catalog.json": {
31
53
  "path": "data/cve-catalog.json",
32
54
  "purpose": "Per-CVE record (CVSS, EPSS, CISA KEV, RWEP, AI-discovery, vendor advisories, framework gaps, ATLAS/ATT&CK mappings). Cross-validated against NVD + CISA KEV + FIRST EPSS via validate-cves.",
@@ -0,0 +1,96 @@
1
+ {
2
+ "_meta": {
3
+ "schema_version": "1.0.0",
4
+ "last_updated": "2026-05-13",
5
+ "attack_version": "v17",
6
+ "attack_version_date": "2025-06-25",
7
+ "source": "https://attack.mitre.org — MITRE ATT&CK Enterprise + ICS. Only techniques currently referenced by shipped exceptd skills and playbooks. The full ATT&CK matrix (~700 techniques) is intentionally not duplicated here; this is a resolution catalog for cross-reference validation, not a substitute for attack.mitre.org. See `npm run refresh-attack-techniques` (v0.13.0+) for the full corpus.",
8
+ "tlp": "CLEAR",
9
+ "source_confidence": {
10
+ "scheme": "Admiralty (A-F + 1-6)",
11
+ "default": "A1",
12
+ "note": "A1 (completely reliable, confirmed) — MITRE ATT&CK is a public reference catalog. Per-entry overrides are not currently used; if an entry's mapping is uncertain it is left out of the catalog rather than carried with reduced confidence."
13
+ },
14
+ "freshness_policy": {
15
+ "default_review_cadence_days": 90,
16
+ "stale_after_days": 180,
17
+ "rebuild_after_days": 365,
18
+ "note": "Catalog must be rebuilt against the upstream ATT&CK release whenever MITRE publishes a new version. AGENTS.md hard rule #8 requires the bump to be intentional, not silent."
19
+ }
20
+ },
21
+ "T0001": { "name": "Authority Spoof", "version": "v17" },
22
+ "T0017": { "name": "Spearphishing Attachment (ICS)", "version": "v17" },
23
+ "T0051": { "name": "Position Tampering", "version": "v17" },
24
+ "T0096": { "name": "Remote System Discovery (ICS)", "version": "v17" },
25
+ "T0853": { "name": "Scripting", "version": "v17" },
26
+ "T0855": { "name": "Unauthorized Command Message", "version": "v17" },
27
+ "T0867": { "name": "Lateral Tool Transfer", "version": "v17" },
28
+ "T0883": { "name": "Internet Accessible Device", "version": "v17" },
29
+ "T1021": { "name": "Remote Services", "version": "v17" },
30
+ "T1027": { "name": "Obfuscated Files or Information", "version": "v17" },
31
+ "T1040": { "name": "Network Sniffing", "version": "v17" },
32
+ "T1041": { "name": "Exfiltration Over C2 Channel", "version": "v17" },
33
+ "T1053.003": { "name": "Scheduled Task/Job: Cron", "version": "v17" },
34
+ "T1055": { "name": "Process Injection", "version": "v17" },
35
+ "T1059": { "name": "Command and Scripting Interpreter", "version": "v17" },
36
+ "T1068": { "name": "Exploitation for Privilege Escalation", "version": "v17" },
37
+ "T1071": { "name": "Application Layer Protocol", "version": "v17" },
38
+ "T1078": { "name": "Valid Accounts", "version": "v17" },
39
+ "T1078.002": { "name": "Valid Accounts: Domain Accounts", "version": "v17" },
40
+ "T1078.003": { "name": "Valid Accounts: Local Accounts", "version": "v17" },
41
+ "T1078.004": { "name": "Valid Accounts: Cloud Accounts", "version": "v17" },
42
+ "T1098": { "name": "Account Manipulation", "version": "v17" },
43
+ "T1102": { "name": "Web Service", "version": "v17" },
44
+ "T1110": { "name": "Brute Force", "version": "v17" },
45
+ "T1110.001": { "name": "Brute Force: Password Guessing", "version": "v17" },
46
+ "T1133": { "name": "External Remote Services", "version": "v17" },
47
+ "T1136.001": { "name": "Create Account: Local Account", "version": "v17" },
48
+ "T1190": { "name": "Exploit Public-Facing Application", "version": "v17" },
49
+ "T1195": { "name": "Supply Chain Compromise", "version": "v17" },
50
+ "T1195.001": { "name": "Supply Chain Compromise: Software Dependencies and Development Tools", "version": "v17" },
51
+ "T1195.002": { "name": "Supply Chain Compromise: Software Supply Chain", "version": "v17" },
52
+ "T1199": { "name": "Trusted Relationship", "version": "v17" },
53
+ "T1203": { "name": "Exploitation for Client Execution", "version": "v17" },
54
+ "T1212": { "name": "Exploitation for Credential Access", "version": "v17" },
55
+ "T1213": { "name": "Data from Information Repositories", "version": "v17" },
56
+ "T1485": { "name": "Data Destruction", "version": "v17" },
57
+ "T1486": { "name": "Data Encrypted for Impact", "version": "v17" },
58
+ "T1505": { "name": "Server Software Component", "version": "v17" },
59
+ "T1518": { "name": "Software Discovery", "version": "v17" },
60
+ "T1525": { "name": "Implant Internal Image", "version": "v17" },
61
+ "T1528": { "name": "Steal Application Access Token", "version": "v17" },
62
+ "T1530": { "name": "Data from Cloud Storage", "version": "v17" },
63
+ "T1543": { "name": "Create or Modify System Process", "version": "v17" },
64
+ "T1546": { "name": "Event Triggered Execution", "version": "v17" },
65
+ "T1547": { "name": "Boot or Logon Autostart Execution", "version": "v17" },
66
+ "T1548.001": { "name": "Abuse Elevation Control Mechanism: Setuid and Setgid", "version": "v17" },
67
+ "T1548.003": { "name": "Abuse Elevation Control Mechanism: Sudo and Sudo Caching", "version": "v17" },
68
+ "T1552": { "name": "Unsecured Credentials", "version": "v17" },
69
+ "T1552.001": { "name": "Unsecured Credentials: Credentials In Files", "version": "v17" },
70
+ "T1552.004": { "name": "Unsecured Credentials: Private Keys", "version": "v17" },
71
+ "T1552.005": { "name": "Unsecured Credentials: Cloud Instance Metadata API", "version": "v17" },
72
+ "T1552.007": { "name": "Unsecured Credentials: Container API", "version": "v17" },
73
+ "T1554": { "name": "Compromise Host Software Binary", "version": "v17" },
74
+ "T1555": { "name": "Credentials from Password Stores", "version": "v17" },
75
+ "T1556": { "name": "Modify Authentication Process", "version": "v17" },
76
+ "T1557": { "name": "Adversary-in-the-Middle", "version": "v17" },
77
+ "T1562.001": { "name": "Impair Defenses: Disable or Modify Tools", "version": "v17" },
78
+ "T1562.006": { "name": "Impair Defenses: Indicator Blocking", "version": "v17" },
79
+ "T1565": { "name": "Data Manipulation", "version": "v17" },
80
+ "T1566": { "name": "Phishing", "version": "v17" },
81
+ "T1566.001": { "name": "Phishing: Spearphishing Attachment", "version": "v17" },
82
+ "T1566.002": { "name": "Phishing: Spearphishing Link", "version": "v17" },
83
+ "T1566.003": { "name": "Phishing: Spearphishing via Service", "version": "v17" },
84
+ "T1567": { "name": "Exfiltration Over Web Service", "version": "v17" },
85
+ "T1568": { "name": "Dynamic Resolution", "version": "v17" },
86
+ "T1570": { "name": "Lateral Tool Transfer", "version": "v17" },
87
+ "T1573": { "name": "Encrypted Channel", "version": "v17" },
88
+ "T1574": { "name": "Hijack Execution Flow", "version": "v17" },
89
+ "T1574.005": { "name": "Hijack Execution Flow: Executable Installer File Permissions Weakness", "version": "v17" },
90
+ "T1595": { "name": "Active Scanning", "version": "v17" },
91
+ "T1600": { "name": "Weaken Encryption", "version": "v17" },
92
+ "T1606.001": { "name": "Forge Web Credentials: Web Cookies", "version": "v17" },
93
+ "T1610": { "name": "Deploy Container", "version": "v17" },
94
+ "T1611": { "name": "Escape to Host", "version": "v17" },
95
+ "T1613": { "name": "Container and Resource Discovery", "version": "v17" }
96
+ }