@blamejs/exceptd-skills 0.12.40 → 0.12.41

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/CONTEXT.md CHANGED
@@ -114,7 +114,7 @@ Skills and playbooks read from `data/`. Authoritative catalog inventory:
114
114
  | File | Entries | Purpose |
115
115
  |------|---------|---------|
116
116
  | `cve-catalog.json` | 10 | CVEs with CVSS, RWEP score, EPSS estimates, CISA KEV flags, PoC and live-patch availability |
117
- | `atlas-ttps.json` | 15 | MITRE ATLAS v5.1.0 (November 2025) techniques with framework gap flags |
117
+ | `atlas-ttps.json` | 15 | MITRE ATLAS v5.4.0 (February 2026) techniques with framework gap flags |
118
118
  | `attack-techniques.json` | 79 | MITRE ATT&CK techniques with framework coverage mappings |
119
119
  | `framework-control-gaps.json` | 62 | Framework control gap entries: designed-for vs. what each control misses |
120
120
  | `exploit-availability.json` | 10 | Per-CVE PoC locations, weaponization stage, AI-acceleration factor, live-patch status |
@@ -245,7 +245,7 @@ The `researcher` **skill** (front-door dispatcher) and `threat-researcher` **age
245
245
  |------|------------|
246
246
  | RWEP | Real-World Exploit Priority — risk score beyond CVSS |
247
247
  | KEV | CISA Known Exploited Vulnerabilities catalog |
248
- | ATLAS | MITRE ATLAS v5.1.0 — AI threat framework |
248
+ | ATLAS | MITRE ATLAS v5.4.0 — AI threat framework |
249
249
  | MCP | Model Context Protocol — AI tool integration standard |
250
250
  | HNDL | Harvest-Now-Decrypt-Later — quantum threat to current crypto |
251
251
  | Framework lag | Gap between what a framework requires and what current TTPs demand |
package/README.md CHANGED
@@ -30,13 +30,7 @@ This platform surfaces what is actually happening right now. Every skill explici
30
30
 
31
31
  ## Status
32
32
 
33
- Pre-1.0. Latest release lives on [GitHub Releases](https://github.com/blamejs/exceptd-skills/releases) and on npm as [`@blamejs/exceptd-skills`](https://www.npmjs.com/package/@blamejs/exceptd-skills) (signed npm provenance attestation). 42 skills across kernel LPE, AI attack surface, MCP trust, RAG security, AI-API C2 detection, PQC migration, framework gap analysis, compliance theater, exploit scoring, threat-model currency, zero-day learning, global GRC, policy exception generation, security maturity tiers, skill update loop, attack-surface pen testing, fuzz testing, DLP gap analysis, supply-chain integrity, defensive-countermeasure mapping, identity assurance, OT/ICS security, coordinated vulnerability disclosure, threat-modeling methodology, child-safety age gates, plus sector packs (federal, financial, healthcare, energy) — and a `researcher` triage dispatcher. 10 data catalogs cover CVE / ATLAS / ATT&CK / CWE / D3FEND / DLP / RFC / framework gaps / global frameworks / zero-day lessons. 35 jurisdictions tracked. AI-consumer ergonomics: `data/_indexes/` ships 17 pre-computed indexes (xref / chains / dispatch / DiD ladders / theater fingerprints / recipes / token budget / currency / activity feed) regenerated by `npm run build-indexes`. External-data refresh is automated nightly via `.github/workflows/refresh.yml` KEV/EPSS/NVD/RFC drift opens an auto-PR with deltas pre-applied; KEV adds new CVEs and IETF discovery auto-imports new RFCs across 48 project-relevant working groups (`_auto_imported` annotation flags entries for human curation); ATLAS/ATT&CK/CWE/D3FEND version bumps open an issue (audit required per AGENTS.md Hard Rule #12). `exceptd doctor --signatures` prints dual SHA-256 + SHA3-512 public-key fingerprints for out-of-band key pinning. `exceptd discover` probes 22 PQC algorithms across the full NIST + IETF emerging landscape. `exceptd framework-gap <framework> <scenario>` provides a non-AI programmatic runner for the framework-gap skill.
34
-
35
- **v0.10.0 introduced the seven-phase playbook contract** — exceptd ships playbooks under `data/playbooks/*.json` that host AIs (Claude Code, Cursor, Gemini CLI, Codex) execute through seven phases: `govern → direct → look → detect → analyze → validate → close`. exceptd owns govern / direct / analyze / validate / close (knowledge + GRC layer); the host AI owns look / detect (artifact collection + indicator evaluation with its native Bash/Read/Grep/Glob).
36
-
37
- **v0.11.0 collapses the 21-verb CLI into 11 canonical verbs** + flips the default output to human-readable. The new surface: `discover` (scan cwd → recommend playbooks), `brief` (unified info doc, replaces plan + govern + direct + look), `run` (phases 4-7, with flat or nested submission shape, auto-detect cwd context), `ai-run` (JSONL streaming variant for AI conversational flow), `attest` (subverbs: list / show / export / verify / diff — replaces reattest + list-attestations), `doctor` (one-shot health check — signatures + currency + cve/rfc validation + signing status), `ci` (one-shot CI gate, exit-2 on detected or rwep ≥ escalate), `ask` (plain-English routing), `lint` (pre-flight submission shape check). Attestation root moved from cwd-relative `.exceptd/` to `~/.exceptd/attestations/<repo-or-host-tag>/`. v0.10.x verbs (`plan`/`govern`/`direct`/`look`/`scan`/`dispatch`/`currency`/`verify`/`validate-cves`/`validate-rfcs`/`watchlist`/`prefetch`/`build-indexes`/`ingest`/`reattest`/`list-attestations`) still work via one-time deprecation banner — scheduled for removal in v0.13.
38
-
39
- **v0.11 series** — CLI ergonomics and signature-verify hardening: mutex filesystem lockfile, `--vex` filter, `--ci` exit-code gating, `--diff-from-latest`, `--operator`/`--ack` attestation binding, `--format <fmt>` transforms output for `run` and `ci`, `ask` synonym routing, `lint` shares the normalize contract with the runner, CSAF/SARIF/OpenVEX bundles include indicator hits and framework gaps for posture-only playbooks, CSAF `current_release_date` populated, SARIF rule definitions for every ruleId, `doctor --fix` repairs a missing private key, `--strict-preconditions` flag, default human output for `attest list` and `lint` on TTY. Regression coverage at `tests/operator-bugs.test.js` catches re-introductions at `npm test`.
33
+ Pre-1.0. Latest release lives on [GitHub Releases](https://github.com/blamejs/exceptd-skills/releases) and on npm as [`@blamejs/exceptd-skills`](https://www.npmjs.com/package/@blamejs/exceptd-skills) with signed npm provenance attestation and Ed25519-signed skill bodies. The package ships 42 skills across kernel LPE, MCP supply chain, AI-as-C2, prompt injection, post-quantum crypto, SBOM integrity, identity-incident response, and 35 other AI/security domains, plus 10 intelligence catalogs (CVE / ATLAS / ATT&CK / CWE / D3FEND / DLP / RFC / framework gaps / global frameworks / zero-day lessons) covering 35 jurisdictions, a CLI for discovery and seven-phase investigation runs (`govern direct look detect analyze validate close`), and a nightly auto-refresh job that pulls KEV / NVD / EPSS / GHSA / OSV / IETF deltas into auto-PRs for editorial review.
40
34
 
41
35
  ---
42
36
 
@@ -178,7 +172,7 @@ You're adding a skill, updating a catalog, or cutting a release. Clone + bootstr
178
172
  git clone https://github.com/blamejs/exceptd-skills
179
173
  cd exceptd-skills
180
174
  npm run bootstrap # auto-detects: verify-only / re-sign / first-init
181
- npm run predeploy # full 14-gate CI sequence locally
175
+ npm run predeploy # full predeploy gate sequence locally
182
176
  ```
183
177
 
184
178
  `bootstrap` auto-detects the right mode based on which keys exist on disk:
@@ -52,9 +52,9 @@ Research and validate new threat intelligence — CVEs, attack campaigns, new AT
52
52
  - Distinguish: "CISA KEV confirmed" vs. "suspected" vs. "no evidence"
53
53
 
54
54
  6. **Map to ATLAS/ATT&CK**
55
- - Identify which ATLAS v5.1.0 TTPs are relevant to this CVE's attack vector
55
+ - Identify which ATLAS v5.4.0 TTPs are relevant to this CVE's attack vector
56
56
  - Identify which ATT&CK techniques are relevant
57
- - Flag any ATLAS gaps (attack pattern not in ATLAS v5.1.0)
57
+ - Flag any ATLAS gaps (attack pattern not in ATLAS v5.4.0)
58
58
 
59
59
  7. **Identify affected skills**
60
60
  - Which skills cover the CVE's technology domain?
package/bin/exceptd.js CHANGED
@@ -528,7 +528,7 @@ function main() {
528
528
  `Legacy verbs remain functional through this release; they will be removed in v0.13. ` +
529
529
  `This banner shows once per exceptd version per host (re-shown on upgrade). Permanent suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
530
530
  );
531
- try { fs.writeFileSync(markerFile, `shown_at=${new Date().toISOString()}\nversion=${ver}\n`); }
531
+ try { fs.writeFileSync(markerFile, `shown_at=${new Date().toISOString()}\nversion=${ver}\n`, { mode: 0o600 }); }
532
532
  catch { /* tmpdir unwritable; the env-var guard below keeps the per-process suppression intact */ }
533
533
  }
534
534
  process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
@@ -2858,8 +2858,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2858
2858
  // exit-code expectations regardless of which verb they call. Without
2859
2859
  // --ci the legacy exit 1 is preserved (ok:false bodies are framework
2860
2860
  // signals when no CI gating is requested).
2861
- process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
2861
+ // Set exitCode BEFORE emit(): emit's ok:false fallback only fires when
2862
+ // exitCode is not already set, so the BLOCKED override survives.
2862
2863
  process.exitCode = args.ci ? EXIT_CODES.BLOCKED : EXIT_CODES.GENERIC_FAILURE;
2864
+ emit(result, pretty);
2863
2865
  return;
2864
2866
  }
2865
2867
 
@@ -3535,8 +3537,7 @@ function cmdIngest(runner, args, runOpts, pretty) {
3535
3537
  }
3536
3538
 
3537
3539
  if (result && result.ok === false) {
3538
- process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
3539
- process.exitCode = EXIT_CODES.GENERIC_FAILURE;
3540
+ emit(result, pretty);
3540
3541
  return;
3541
3542
  }
3542
3543
  emit(result, pretty);
@@ -3897,13 +3898,18 @@ function maybeSignAttestation(filePath) {
3897
3898
  algorithm: "Ed25519",
3898
3899
  signature_base64: sig.toString("base64"),
3899
3900
  note: "Ed25519 signature covers the attestation file bytes only. Use filesystem mtime for freshness; use the attestation's `captured_at` for the signed timestamp.",
3900
- }, null, 2));
3901
+ }, null, 2), { mode: 0o600 });
3902
+ // Mirror the v0.12.38 attestation.json hardening: 0o600 on POSIX +
3903
+ // icacls inheritance strip on win32. The sidecar carries the
3904
+ // signature payload; multi-tenant hosts shouldn't leak it.
3905
+ try { require("./../lib/sign.js").restrictWindowsAcl(sigPath); } catch { /* best-effort */ }
3901
3906
  } else {
3902
3907
  fs.writeFileSync(sigPath, JSON.stringify({
3903
3908
  algorithm: "unsigned",
3904
3909
  signed: false,
3905
- note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
3906
- }, null, 2));
3910
+ note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `exceptd doctor --fix` to enable signing.",
3911
+ }, null, 2), { mode: 0o600 });
3912
+ try { require("./../lib/sign.js").restrictWindowsAcl(sigPath); } catch { /* best-effort */ }
3907
3913
  }
3908
3914
  } catch { /* non-fatal — signing failure shouldn't block the run */ }
3909
3915
  }
@@ -4477,6 +4483,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
4477
4483
  if (!subverb) {
4478
4484
  return emitError("attest: missing subverb. Usage: attest list | show <sid> | export <sid> | verify <sid> | diff <sid>", null, pretty);
4479
4485
  }
4486
+ // Validate subverb membership BEFORE the session-id branch so a typo
4487
+ // (`attest verfy sid`) gets the did-you-mean response, not the
4488
+ // misleading "no session dir for sid" downstream. Pre-fix the
4489
+ // session-id resolution ran first and a valid-but-unrecognized
4490
+ // subverb collapsed into a session-lookup failure.
4491
+ const ATTEST_SUBVERBS = ["list", "show", "export", "verify", "diff"];
4492
+ if (!ATTEST_SUBVERBS.includes(subverb)) {
4493
+ const dym = suggestVerb(subverb, ATTEST_SUBVERBS);
4494
+ const hint = dym.length > 0
4495
+ ? `Did you mean: ${dym.join(" | ")}? Accepted: ${ATTEST_SUBVERBS.join(" | ")}.`
4496
+ : `Accepted: ${ATTEST_SUBVERBS.join(" | ")}.`;
4497
+ return emitError(
4498
+ `attest: unknown subverb "${subverb}". ${hint}`,
4499
+ { verb: "attest", subverb_input: subverb, did_you_mean: dym, accepted_subverbs: ATTEST_SUBVERBS },
4500
+ pretty
4501
+ );
4502
+ }
4480
4503
  // `list` doesn't require a session-id positional.
4481
4504
  if (subverb === "list") {
4482
4505
  return cmdListAttestations(runner, args, runOpts, pretty);
@@ -4568,6 +4591,15 @@ function cmdAttest(runner, args, runOpts, pretty) {
4568
4591
  return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
4569
4592
  }
4570
4593
  const self = attestations[0];
4594
+ if (!self) {
4595
+ // Session dir contains only replay records, no attestation —
4596
+ // diff has nothing to compare on the A side.
4597
+ return emitError(
4598
+ `attest diff ${sessionId}: no attestation found in session dir (only replay records). The session may be replay-only; verify with \`exceptd attest show ${sessionId}\`.`,
4599
+ { verb: "attest diff", session_id: sessionId, attestation_count: 0, replay_count: replays.length },
4600
+ pretty
4601
+ );
4602
+ }
4571
4603
  emit({
4572
4604
  verb: "attest diff",
4573
4605
  a_session: sessionId,
@@ -4599,10 +4631,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
4599
4631
  return cmdReattest(runner, args, {}, pretty);
4600
4632
  }
4601
4633
 
4602
- if (subverb === "list") {
4603
- return cmdListAttestations(runner, args, {}, pretty);
4604
- }
4605
-
4606
4634
  if (subverb === "verify") {
4607
4635
  const crypto = require("crypto");
4608
4636
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
@@ -4796,7 +4824,10 @@ function cmdAttest(runner, args, runOpts, pretty) {
4796
4824
  return;
4797
4825
  }
4798
4826
 
4799
- return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
4827
+ // Unreachable front-loaded subverb membership check above handles
4828
+ // unknown subverbs. Defensive return so future refactors that move
4829
+ // the gate don't silently fall through.
4830
+ return emitError(`attest: unknown subverb "${subverb}".`, { verb: "attest", subverb_input: subverb }, pretty);
4800
4831
  }
4801
4832
 
4802
4833
  /**
@@ -5335,7 +5366,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
5335
5366
  severity: present ? "info" : "warn",
5336
5367
  private_key_present: present,
5337
5368
  can_sign_attestations: present,
5338
- ...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` (or `exceptd doctor --fix`) to enable attestation signing" }),
5369
+ ...(present ? {} : { hint: "run `exceptd doctor --fix` to generate an Ed25519 keypair and sign skills (or `node $(exceptd path)/lib/sign.js generate-keypair` from a contributor checkout)" }),
5339
5370
  };
5340
5371
  } catch (e) {
5341
5372
  checks.signing = { ok: false, error: e.message };
@@ -5410,27 +5441,78 @@ function cmdDoctor(runner, args, runOpts, pretty) {
5410
5441
  },
5411
5442
  };
5412
5443
 
5413
- // v0.11.6 (#97): --fix runs BEFORE the JSON early-return so `exceptd doctor
5414
- // --fix --json` actually fixes (was a no-op pre-0.11.6). Re-runs the
5415
- // signing check after fix so the returned JSON reflects the post-fix state.
5444
+ // --fix runs BEFORE the JSON early-return so `exceptd doctor --fix --json`
5445
+ // actually fixes (was a no-op pre-v0.11.6). Re-runs the signing check
5446
+ // after fix so the returned JSON reflects the post-fix state.
5447
+ //
5448
+ // Safety: lib/sign.js generateKeypair() refuses if keys/public.pem
5449
+ // already exists (overwriting it would orphan every shipped signature —
5450
+ // the v0.11.x regression class). Surface that refusal as a distinct
5451
+ // fix_attempted reason so operators see WHY the fix declined.
5452
+ // After successful key generation, chain sign-all so the manifest +
5453
+ // every shipped skill carries a signature paired with the new public
5454
+ // key. Without this chain, `doctor --fix` succeeds but the very next
5455
+ // `exceptd doctor` (signatures check) reports 0/N passing.
5416
5456
  if (args.fix && checks.signing && !checks.signing.private_key_present) {
5417
- process.stderr.write("[doctor --fix] generating Ed25519 keypair via `node lib/sign.js generate-keypair`...\n");
5418
- const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], {
5457
+ const pubKeyExists = fs.existsSync(path.join(PKG_ROOT, "keys", "public.pem"));
5458
+ if (pubKeyExists) {
5459
+ out.summary.fix_attempted = "ed25519_keypair_generation_declined";
5460
+ out.summary.fix_decline_reason = "keys/public.pem already exists but no matching private key. Generating a fresh keypair would overwrite the public key and orphan every shipped signature. If you intend to establish a new signing identity, run `node $(exceptd path)/lib/sign.js generate-keypair --rotate` followed by sign-all.";
5461
+ process.stderr.write("[doctor --fix] refused: keys/public.pem present without matching private key. Pass --rotate via the underlying lib/sign.js if a new identity is intended.\n");
5462
+ } else {
5463
+ process.stderr.write("[doctor --fix] generating Ed25519 keypair...\n");
5464
+ const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], {
5465
+ stdio: ["ignore", "pipe", "pipe"], cwd: PKG_ROOT,
5466
+ });
5467
+ if (r.status === 0) {
5468
+ // Chain sign-all so the manifest + skills carry signatures paired
5469
+ // with the new keypair. Without this every shipped signature is
5470
+ // invalid against the new public key.
5471
+ process.stderr.write("[doctor --fix] keypair generated — signing skills + manifest...\n");
5472
+ const s = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "sign-all"], {
5473
+ stdio: ["ignore", "pipe", "pipe"], cwd: PKG_ROOT,
5474
+ });
5475
+ const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
5476
+ const present = fs.existsSync(keyPath);
5477
+ checks.signing = { ok: present, severity: present ? "info" : "warn", private_key_present: present, can_sign_attestations: present };
5478
+ out.checks = checks;
5479
+ if (s.status === 0) {
5480
+ out.summary.fix_applied = "ed25519_keypair_generated_and_skills_signed";
5481
+ process.stderr.write("[doctor --fix] keypair + sign-all complete — re-checking signing status.\n");
5482
+ } else {
5483
+ out.summary.fix_applied = "ed25519_keypair_generated";
5484
+ out.summary.fix_partial = "sign_all_failed";
5485
+ out.summary.sign_all_exit_code = s.status;
5486
+ process.stderr.write(`[doctor --fix] WARNING: keypair generated but sign-all failed (exit=${s.status}). Skills carry signatures from a different key; verify will report mismatches.\n`);
5487
+ }
5488
+ } else {
5489
+ out.summary.fix_attempted = "ed25519_keypair_generation_failed";
5490
+ out.summary.fix_exit_code = r.status;
5491
+ process.stderr.write(`[doctor --fix] generation failed (exit=${r.status}); run \`node $(exceptd path)/lib/sign.js generate-keypair\` manually.\n`);
5492
+ }
5493
+ }
5494
+ }
5495
+
5496
+ // Second --fix path: private key IS present but the signatures check
5497
+ // FAILED. This is the post-rotation case (codex P2 v0.12.41): operator
5498
+ // ran `node $(exceptd path)/lib/sign.js generate-keypair --rotate`,
5499
+ // got a fresh keypair, but the manifest + skills still carry signatures
5500
+ // from the OLD keypair. Pre-fix doctor --fix's signing path only fired
5501
+ // when the private key was missing, so the rotation flow's remediation
5502
+ // step was a no-op. Chain sign-all here so the post-rotate doctor --fix
5503
+ // converges to a fully-verified state.
5504
+ if (args.fix && checks.signing && checks.signing.private_key_present && checks.signatures && checks.signatures.ok === false && !out.summary.fix_applied && !out.summary.fix_attempted) {
5505
+ process.stderr.write("[doctor --fix] private key present, signatures failing — running sign-all to re-sign skills + manifest...\n");
5506
+ const s = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "sign-all"], {
5419
5507
  stdio: ["ignore", "pipe", "pipe"], cwd: PKG_ROOT,
5420
5508
  });
5421
- if (r.status === 0) {
5422
- // Re-verify the private key is now present so the JSON output reflects
5423
- // the fix. v0.12.9 codex P1: PKG_ROOT-only (sign + verify use this path).
5424
- const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
5425
- const present = fs.existsSync(keyPath);
5426
- checks.signing = { ok: present, severity: present ? "info" : "warn", private_key_present: present, can_sign_attestations: present };
5427
- out.checks = checks;
5428
- out.summary.fix_applied = "ed25519_keypair_generated";
5429
- process.stderr.write("[doctor --fix] keypair generated — re-checking signing status.\n");
5509
+ if (s.status === 0) {
5510
+ out.summary.fix_applied = "skills_resigned_against_current_keypair";
5511
+ process.stderr.write("[doctor --fix] sign-all complete re-run `exceptd doctor` to confirm.\n");
5430
5512
  } else {
5431
- out.summary.fix_attempted = "ed25519_keypair_generation_failed";
5432
- out.summary.fix_exit_code = r.status;
5433
- process.stderr.write(`[doctor --fix] generation failed (exit=${r.status}); run \`node lib/sign.js generate-keypair\` manually.\n`);
5513
+ out.summary.fix_attempted = "sign_all_failed";
5514
+ out.summary.sign_all_exit_code = s.status;
5515
+ process.stderr.write(`[doctor --fix] sign-all failed (exit=${s.status}); run \`node $(exceptd path)/lib/sign.js sign-all\` manually.\n`);
5434
5516
  }
5435
5517
  }
5436
5518
 
@@ -5509,7 +5591,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
5509
5591
  if (checks.signing.private_key_present) {
5510
5592
  lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
5511
5593
  } else {
5512
- lines.push(` [!!] attestation signing: private key MISSING (.keys/private.pem) — run \`node lib/sign.js generate-keypair\` to enable`);
5594
+ lines.push(` [!!] attestation signing: private key MISSING (.keys/private.pem) — run \`exceptd doctor --fix\` to enable`);
5513
5595
  }
5514
5596
  }
5515
5597
  lines.push("");
@@ -5526,7 +5608,11 @@ function cmdDoctor(runner, args, runOpts, pretty) {
5526
5608
  if (out.summary.fix_applied) {
5527
5609
  process.stdout.write(`\n[doctor --fix] ${out.summary.fix_applied} — re-run \`exceptd doctor\` to confirm.\n`);
5528
5610
  } else if (out.summary.fix_attempted) {
5529
- process.stdout.write(`\n[doctor --fix] ${out.summary.fix_attempted} (exit=${out.summary.fix_exit_code}); run \`node lib/sign.js generate-keypair\` manually.\n`);
5611
+ if (out.summary.fix_decline_reason) {
5612
+ process.stdout.write(`\n[doctor --fix] ${out.summary.fix_attempted}: ${out.summary.fix_decline_reason}\n`);
5613
+ } else {
5614
+ process.stdout.write(`\n[doctor --fix] ${out.summary.fix_attempted} (exit=${out.summary.fix_exit_code}); run \`node $(exceptd path)/lib/sign.js generate-keypair\` from a contributor checkout if needed.\n`);
5615
+ }
5530
5616
  process.exitCode = EXIT_CODES.GENERIC_FAILURE;
5531
5617
  return;
5532
5618
  }
@@ -5764,10 +5850,11 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5764
5850
  );
5765
5851
  }
5766
5852
  if (!result || result.ok === false) {
5767
- // v0.12.12: same exit-after-write anti-pattern as the pre-stream
5768
- // load path. Use exitCode + return so stderr drains.
5769
- process.stderr.write((pretty ? JSON.stringify(result || {}, null, 2) : JSON.stringify(result || {})) + "\n");
5770
- process.exitCode = EXIT_CODES.GENERIC_FAILURE;
5853
+ // Route through emit() so the body lands on stdout (per v0.12.39
5854
+ // envelope contracts) and exitCode is set by the shared ok:false
5855
+ // fallback. Pre-fix the body went to stderr, which split it from
5856
+ // the success path and made consumers parse two streams.
5857
+ emit(result || { ok: false, error: 'ai-run returned empty result' }, pretty);
5771
5858
  return;
5772
5859
  }
5773
5860
  // v0.12.14: ai-run --no-stream previously emitted a
@@ -6132,7 +6219,10 @@ function cmdAsk(runner, args, runOpts, pretty) {
6132
6219
  routed_to: [],
6133
6220
  hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
6134
6221
  };
6135
- if (args.json) return emit(result, pretty);
6222
+ // Honor --pretty as an implicit opt-in to structured output, matching
6223
+ // the discover/doctor convention. Pre-fix `ask "..." --pretty` fell
6224
+ // into the human-text branch and silently ignored the flag.
6225
+ if (args.json || args.pretty) return emit(result, pretty);
6136
6226
  process.stdout.write(`ask: ${question}\n no playbook matched.\n try: exceptd discover (auto-detect what's in your cwd)\n`);
6137
6227
  return;
6138
6228
  }
@@ -6145,7 +6235,7 @@ function cmdAsk(runner, args, runOpts, pretty) {
6145
6235
  next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
6146
6236
  full_match_list: top,
6147
6237
  };
6148
- if (args.json) return emit(result, pretty);
6238
+ if (args.json || args.pretty) return emit(result, pretty);
6149
6239
  process.stdout.write(`ask: ${question}\n top match: ${top[0].id} (score ${top[0].score})\n next: ${result.next_step}\n alternates: ${top.slice(1).map(t => t.id).join(", ") || "(none)"}\n`);
6150
6240
  }
6151
6241
 
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-16T15:50:46.737Z",
3
+ "generated_at": "2026-05-17T20:21:35.517Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "32dc1f7ebdf72e65bac8a969abc45e539f506ca4ec9149cd548d72fb41252548",
7
+ "manifest.json": "adcee350ac3e4ffc9a6edd5003735df10503bffcd7474e298d1ecddb1e948224",
8
8
  "data/atlas-ttps.json": "259e76e4252c7a56c17bbe96982a5e37ac89131c2d37a547fe38d64dcacfd763",
9
9
  "data/attack-techniques.json": "51f60819aef36e960fd768e44dcc725e137781534fbbb028e5ef6baa21defa1d",
10
- "data/cve-catalog.json": "5bfd08a3fc62850e0cdaf454a2dce3e6719acb8917b7c7249c4c02bf945b62f5",
10
+ "data/cve-catalog.json": "415384660f879d26491a612e579943092135acb676cbb95327f309c921ff69ed",
11
11
  "data/cwe-catalog.json": "6e7349a0fac39bdf9c4cb4598e101e51400f67d64c5d653bbca462f28bc1a0cb",
12
12
  "data/d3fend-catalog.json": "a1fc2827ceb344669e148d55197dbf1b0e5b20bcc618e90517639c17d67ee82d",
13
13
  "data/dlp-controls.json": "d2406c482dddd30e49203879999dc4b3a7fd4d0494d6a61d86b91ee76415df19",
14
- "data/exploit-availability.json": "a9eeda95d24b56c28a0d0178fc601b531653e2ba7dc857160b35ad23ad6c7471",
15
- "data/framework-control-gaps.json": "aa66fa78b0aad53767755532e41e391a41e75d7d81d77d3fad5eaa744f32f4de",
16
- "data/global-frameworks.json": "0168825497e03f079274c9da2e5529310a2ba5bd7c7da7c93acd0b66ed845b8a",
14
+ "data/exploit-availability.json": "003a400f5ae5b15527589571679ccdb9b3a62e60073627b5fbdeb2a9fe330a7a",
15
+ "data/framework-control-gaps.json": "45d4b461a733fc31ce5a02b0462d0e23aa5d7469d937d77ad69417eb5293f189",
16
+ "data/global-frameworks.json": "9ba563a85f7f8d6c3c957de64945e20925a89d0ed6ea6fc561cf093811acf558",
17
17
  "data/rfc-references.json": "e253a548c8a829d178d5aea601e268724b85c936ccbfa51c2e5d80c5f8efe2b0",
18
- "data/zeroday-lessons.json": "c927653e6d9d86d1a36c23a3d782b099a49675ccd928cdc204887c79b0cfbbf1",
18
+ "data/zeroday-lessons.json": "27d46a0e09a3edbe97dfbb070c3991348567cf93c86a3e94c767c5ad2dfb653e",
19
19
  "skills/kernel-lpe-triage/skill.md": "8e94bfd38d6db47342fbbe95a0c8df8f7c38743982c13e9de6a1c59cd3783d33",
20
20
  "skills/ai-attack-surface/skill.md": "853ea46b500fa60b5f5db1137629f8b64447b5df2c8346c15c6cbd1e59285532",
21
21
  "skills/mcp-agent-trust/skill.md": "b09a33e71a0cc13ec70e7e750ac4b91887b657d293d92c3cdb49a4e094adcfea",
@@ -51,7 +51,7 @@
51
51
  "skills/api-security/skill.md": "75dcb1b9395de2be4ca60e53f900692721b7ef66ded3e510a20d17f35daf982d",
52
52
  "skills/cloud-security/skill.md": "56f0d5d6cf182d347e84baa95a04c39be51e82da3360dac48fcf5d8c4e56a9c3",
53
53
  "skills/container-runtime-security/skill.md": "7e0806b9e13db120f9b65d5f48b33db9f1026c4c2d719838ef0f0c8778ec4365",
54
- "skills/mlops-security/skill.md": "cb6871691028f55d59e3efe47be2f1d6bf65fa8c6f3cf301e78d5d119fe3616d",
54
+ "skills/mlops-security/skill.md": "a7bf448527e15bb0c6936217c69f3e18cc823f534e0c7f749966cde32039c63e",
55
55
  "skills/incident-response-playbook/skill.md": "0695ee43881527459f657a90276748922347f16dd494ae2b98e2a9396c570a44",
56
56
  "skills/ransomware-response/skill.md": "15de039c5679215b7ceb9a55494f614b06fe618aa0f69ce8aff004dc9a841fa4",
57
57
  "skills/email-security-anti-phishing/skill.md": "b5a7693b3ddbd6cd83303d092bc5e324db431245d25c4945d9f65fcffa1995e7",
@@ -78,7 +78,7 @@
78
78
  "handoff_dag_nodes": 42,
79
79
  "summary_cards": 42,
80
80
  "section_offsets_skills": 42,
81
- "token_budget_total_approx": 397769,
81
+ "token_budget_total_approx": 397775,
82
82
  "recipes": 8,
83
83
  "jurisdiction_clocks": 29,
84
84
  "did_ladders": 8,
@@ -63,7 +63,7 @@
63
63
  "artifact": "data/framework-control-gaps.json",
64
64
  "path": "data/framework-control-gaps.json",
65
65
  "schema_version": "1.0.0",
66
- "entry_count": 118
66
+ "entry_count": 122
67
67
  },
68
68
  {
69
69
  "date": "2026-05-15",
@@ -172,7 +172,7 @@
172
172
  "rebuild_after_days": 365,
173
173
  "note": "Per-entry last_verified governs decay. Skills depending on this catalog must check entry freshness before high-stakes use."
174
174
  },
175
- "entry_count": 118,
175
+ "entry_count": 122,
176
176
  "sample_keys": [
177
177
  "ALL-AI-PIPELINE-INTEGRITY",
178
178
  "ALL-MCP-TOOL-TRUST",
@@ -2520,6 +2520,7 @@
2520
2520
  "AU-Essential-8-Backup",
2521
2521
  "AU-Essential-8-MFA",
2522
2522
  "AU-Essential-8-Patch",
2523
+ "AU-ISM-1546",
2523
2524
  "CIS-Controls-v8-10.1",
2524
2525
  "DORA-Art-9",
2525
2526
  "DORA-Art28",
@@ -2537,9 +2538,11 @@
2537
2538
  "HIPAA-Security-Rule-2026-NPRM-164.310",
2538
2539
  "HIPAA-Security-Rule-2026-NPRM-164.312",
2539
2540
  "HIPAA-Security-Rule-2026-NPRM-164.314",
2541
+ "ISO-27001-2022-A.5.7",
2540
2542
  "ISO-27001-2022-A.8.7",
2541
2543
  "NIS2-Art21-identity-management",
2542
2544
  "NIS2-Art21-incident-handling",
2545
+ "NIS2-Art21-supply-chain",
2543
2546
  "NIS2-Art21-vulnerability-management",
2544
2547
  "NIST-800-53-AC-3",
2545
2548
  "NIST-800-53-AC-6",
@@ -2552,6 +2555,7 @@
2552
2555
  "PCI-DSS-4.0.1-6.4.3",
2553
2556
  "UK-CAF-A1",
2554
2557
  "UK-CAF-B2",
2558
+ "UK-CAF-B4",
2555
2559
  "UK-CAF-C1",
2556
2560
  "UK-CAF-D1"
2557
2561
  ],
@@ -3543,21 +3543,21 @@
3543
3543
  },
3544
3544
  "mlops-security": {
3545
3545
  "path": "skills/mlops-security/skill.md",
3546
- "total_bytes": 45439,
3546
+ "total_bytes": 45463,
3547
3547
  "total_lines": 330,
3548
3548
  "frontmatter": {
3549
3549
  "line_start": 1,
3550
3550
  "line_end": 66,
3551
3551
  "byte_start": 0,
3552
- "byte_end": 2398
3552
+ "byte_end": 2422
3553
3553
  },
3554
3554
  "sections": [
3555
3555
  {
3556
3556
  "name": "Threat Context (mid-2026)",
3557
3557
  "normalized_name": "threat-context",
3558
3558
  "line": 70,
3559
- "byte_start": 2437,
3560
- "byte_end": 8267,
3559
+ "byte_start": 2461,
3560
+ "byte_end": 8291,
3561
3561
  "bytes": 5830,
3562
3562
  "h3_count": 0
3563
3563
  },
@@ -3565,8 +3565,8 @@
3565
3565
  "name": "Framework Lag Declaration",
3566
3566
  "normalized_name": "framework-lag-declaration",
3567
3567
  "line": 88,
3568
- "byte_start": 8267,
3569
- "byte_end": 14049,
3568
+ "byte_start": 8291,
3569
+ "byte_end": 14073,
3570
3570
  "bytes": 5782,
3571
3571
  "h3_count": 0
3572
3572
  },
@@ -3574,8 +3574,8 @@
3574
3574
  "name": "TTP Mapping",
3575
3575
  "normalized_name": "ttp-mapping",
3576
3576
  "line": 112,
3577
- "byte_start": 14049,
3578
- "byte_end": 18425,
3577
+ "byte_start": 14073,
3578
+ "byte_end": 18449,
3579
3579
  "bytes": 4376,
3580
3580
  "h3_count": 0
3581
3581
  },
@@ -3583,8 +3583,8 @@
3583
3583
  "name": "Exploit Availability Matrix",
3584
3584
  "normalized_name": "exploit-availability-matrix",
3585
3585
  "line": 137,
3586
- "byte_start": 18425,
3587
- "byte_end": 23911,
3586
+ "byte_start": 18449,
3587
+ "byte_end": 23935,
3588
3588
  "bytes": 5486,
3589
3589
  "h3_count": 0
3590
3590
  },
@@ -3592,8 +3592,8 @@
3592
3592
  "name": "Analysis Procedure",
3593
3593
  "normalized_name": "analysis-procedure",
3594
3594
  "line": 163,
3595
- "byte_start": 23911,
3596
- "byte_end": 32980,
3595
+ "byte_start": 23935,
3596
+ "byte_end": 33004,
3597
3597
  "bytes": 9069,
3598
3598
  "h3_count": 4
3599
3599
  },
@@ -3601,8 +3601,8 @@
3601
3601
  "name": "Output Format",
3602
3602
  "normalized_name": "output-format",
3603
3603
  "line": 228,
3604
- "byte_start": 32980,
3605
- "byte_end": 35658,
3604
+ "byte_start": 33004,
3605
+ "byte_end": 35682,
3606
3606
  "bytes": 2678,
3607
3607
  "h3_count": 10
3608
3608
  },
@@ -3610,8 +3610,8 @@
3610
3610
  "name": "Compliance Theater Check",
3611
3611
  "normalized_name": "compliance-theater-check",
3612
3612
  "line": 281,
3613
- "byte_start": 35658,
3614
- "byte_end": 38589,
3613
+ "byte_start": 35682,
3614
+ "byte_end": 38613,
3615
3615
  "bytes": 2931,
3616
3616
  "h3_count": 0
3617
3617
  },
@@ -3619,8 +3619,8 @@
3619
3619
  "name": "Defensive Countermeasure Mapping",
3620
3620
  "normalized_name": "defensive-countermeasure-mapping",
3621
3621
  "line": 297,
3622
- "byte_start": 38589,
3623
- "byte_end": 42509,
3622
+ "byte_start": 38613,
3623
+ "byte_end": 42533,
3624
3624
  "bytes": 3920,
3625
3625
  "h3_count": 0
3626
3626
  },
@@ -3628,8 +3628,8 @@
3628
3628
  "name": "Hand-Off / Related Skills",
3629
3629
  "normalized_name": "hand-off",
3630
3630
  "line": 317,
3631
- "byte_start": 42509,
3632
- "byte_end": 45439,
3631
+ "byte_start": 42533,
3632
+ "byte_end": 45463,
3633
3633
  "bytes": 2930,
3634
3634
  "h3_count": 0
3635
3635
  }
@@ -3,8 +3,8 @@
3
3
  "schema_version": "1.0.0",
4
4
  "tokenizer_note": "Character-density approximation: 1 token ≈ 4 chars. This is the canonical rule-of-thumb for OpenAI tokenizers on English+technical text. Claude's tokenizer is typically more efficient on prose; treat this as an upper-bound budget for both. Consumers with stricter precision needs should re-tokenize with their own tokenizer.",
5
5
  "approx_chars_per_token": 4,
6
- "total_chars": 1591052,
7
- "total_approx_tokens": 397769,
6
+ "total_chars": 1591076,
7
+ "total_approx_tokens": 397775,
8
8
  "skill_count": 42
9
9
  },
10
10
  "skills": {
@@ -2065,10 +2065,10 @@
2065
2065
  },
2066
2066
  "mlops-security": {
2067
2067
  "path": "skills/mlops-security/skill.md",
2068
- "bytes": 45439,
2069
- "chars": 45147,
2068
+ "bytes": 45463,
2069
+ "chars": 45171,
2070
2070
  "lines": 330,
2071
- "approx_tokens": 11287,
2071
+ "approx_tokens": 11293,
2072
2072
  "approx_chars_per_token": 4,
2073
2073
  "sections": {
2074
2074
  "threat-context": {