@blamejs/exceptd-skills 0.10.2 → 0.11.0

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/bin/exceptd.js CHANGED
@@ -100,10 +100,38 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
100
100
  ]);
101
101
 
102
102
  // Seven-phase playbook verbs handled in-process (no subprocess dispatch).
103
+ // v0.11.0 introduces: brief (collapses plan/govern/direct/look), discover (scan + dispatch),
104
+ // doctor (currency + verify + validate-cves + validate-rfcs), ci (CI gate),
105
+ // ai-run (streaming JSONL), ask (plain-English routing).
103
106
  const PLAYBOOK_VERBS = new Set([
104
- "plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations",
107
+ // v0.11.0 canonical surface:
108
+ "brief", "run", "ai-run", "attest", "discover", "doctor", "ci", "ask",
109
+ "verify-attestation", "run-all", "lint",
110
+ // v0.10.x legacy verbs — kept as aliases with deprecation banner, removed in v0.12+:
111
+ "plan", "govern", "direct", "look", "ingest", "reattest", "list-attestations",
105
112
  ]);
106
113
 
114
+ // Map legacy verb names to their v0.11.0 replacement so the dispatcher can
115
+ // emit a single deprecation banner per session.
116
+ const LEGACY_VERB_REPLACEMENTS = {
117
+ plan: "brief --all",
118
+ govern: "brief <pb> --phase govern",
119
+ direct: "brief <pb> --phase direct",
120
+ look: "brief <pb> --phase look",
121
+ ingest: "run",
122
+ reattest: "attest diff",
123
+ "list-attestations": "attest list",
124
+ scan: "discover --scan-only",
125
+ dispatch: "discover",
126
+ currency: "doctor --currency",
127
+ verify: "doctor --signatures",
128
+ "validate-cves": "doctor --cves",
129
+ "validate-rfcs": "doctor --rfcs",
130
+ watchlist: "watch",
131
+ prefetch: "refresh --no-network",
132
+ "build-indexes": "refresh --indexes-only",
133
+ };
134
+
107
135
  function readPkgVersion() {
108
136
  try {
109
137
  return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version;
@@ -112,82 +140,133 @@ function readPkgVersion() {
112
140
  }
113
141
  }
114
142
 
143
+ function printWelcome() {
144
+ // v0.11.0 redesign #4 — first-run experience. `exceptd` with no args used to
145
+ // print full help (a wall of text). Now it shows two ways in and where to
146
+ // go from there.
147
+ console.log(`exceptd — @blamejs/exceptd-skills v${readPkgVersion()}
148
+
149
+ Welcome. Two ways to start:
150
+
151
+ exceptd discover # scan this directory + recommend playbooks
152
+ exceptd ask "<question>" # plain-English routing to a playbook
153
+
154
+ If you know what you want:
155
+
156
+ exceptd brief <playbook> # what does this playbook check?
157
+ exceptd run <playbook> # run it
158
+ exceptd ci --scope code # CI gate against every code-scoped playbook
159
+
160
+ Common starting playbooks
161
+ code repos: secrets, sbom, library-author, crypto-codebase
162
+ Linux hosts: kernel, hardening, runtime, cred-stores
163
+ AI / service: ai-api, mcp, crypto
164
+
165
+ Full reference: exceptd help
166
+ Per-verb help: exceptd <verb> --help
167
+ `);
168
+ }
169
+
115
170
  function printHelp() {
116
171
  console.log(`exceptd — @blamejs/exceptd-skills v${readPkgVersion()}
117
172
 
118
173
  Usage: exceptd <command> [args]
119
174
  npx @blamejs/exceptd-skills <command> [args]
120
175
 
121
- Discovery:
122
- path Print absolute path to the installed package.
123
- Point your AI assistant here:
124
- $(exceptd path)/AGENTS.md
125
- $(exceptd path)/data/_indexes/summary-cards.json
126
-
127
- External data:
128
- prefetch [args] Warm local cache of upstream artifacts
129
- (KEV / NVD / EPSS / IETF / GitHub releases).
130
- Try: exceptd prefetch --no-network --quiet
131
- refresh [args] Refresh against cache + apply upserts.
132
- Try: exceptd refresh --from-cache --swarm
133
-
134
- Build / verify:
135
- build-indexes [args] Regenerate data/_indexes/*.json.
136
- Try: exceptd build-indexes --changed
137
- verify Verify every skill's Ed25519 signature.
138
-
139
- Analyst:
140
- scan Scan environment for findings.
141
- dispatch Scan then route findings to skills.
176
+ v0.11.0 canonical surface
177
+ ─────────────────────────
178
+
179
+ brief [playbook] Unified info doc — jurisdictions + threat context
180
+ + preconditions + artifacts + indicators. Replaces
181
+ plan + govern + direct + look.
182
+ --all every playbook
183
+ --scope <type> system | code | service | cross-cutting
184
+ --directives expand directive metadata
185
+ --phase <name> emit only one phase (legacy compat)
186
+
187
+ run [playbook] Phases 4-7. Auto-detects cwd context when no
188
+ playbook positional.
189
+ --scope <type> | --all | run-all (alias)
190
+ --evidence <file|-> flat or nested submission
191
+ --evidence-dir <dir> per-playbook submission files
192
+ --vex <file> CycloneDX / OpenVEX filter
193
+ --format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary
194
+ --diff-from-latest drift vs prior attestation
195
+ --ci exit-code gate (use \`exceptd ci\` instead)
196
+ --operator <name> bind attestation to identity
197
+ --ack explicit jurisdiction-consent
198
+ --session-id <id> reuse session id (collision refused)
199
+ --force-overwrite override session collision refusal
200
+ --session-key <hex> HMAC sign evidence_package
201
+ --force-stale override threat_currency_score<50 gate
202
+ --air-gap honor air_gap_alternative paths
203
+
204
+ ai-run <playbook> JSONL streaming variant of run. AI emits events
205
+ back on stdin; runner streams phase events on stdout.
206
+ --no-stream single-shot mode
207
+
208
+ attest <subverb> <sid> Auditor-facing operations:
209
+ attest show full attestation
210
+ attest list inventory all sessions
211
+ attest export redacted bundle (--format csaf)
212
+ attest verify Ed25519 signature check
213
+ attest diff drift vs prior or --against <other-sid>
214
+
215
+ discover Scan cwd → recommend playbooks. Replaces scan + dispatch.
216
+
217
+ doctor Health check: signatures + currency + cve catalog
218
+ + rfc catalog + attestation-signing status.
219
+ --signatures | --currency | --cves | --rfcs
220
+
221
+ ci One-shot CI gate. Exits 2 on detected or rwep≥escalate.
222
+ --all | --scope <type> | (auto-detect)
223
+ --max-rwep <n> cap below playbook default
224
+ --block-on-jurisdiction-clock
225
+ --evidence-dir <dir>
226
+
227
+ ask "<question>" Plain-English routing to playbook(s).
228
+
229
+ lint <pb> <evidence> Pre-flight check submission shape vs playbook
230
+ (preconditions / artifacts / indicators) without
231
+ executing phases 4-7.
232
+
233
+ verify-attestation <sid> Alias for \`attest verify\`.
234
+ run-all Alias for \`run --all\`.
235
+
142
236
  skill <name> Show context for a specific skill.
143
- currency Skill currency report.
144
- report [format] Compliance / executive / technical report.
145
- validate-cves [args] Cross-check CVE catalog vs NVD/KEV/EPSS.
146
- Add --from-cache to read from prefetch cache.
147
- validate-rfcs [args] Cross-check RFC catalog vs IETF Datatracker.
148
- watchlist [args] Forward-watch aggregator across skills.
149
-
150
- Playbook runner — seven-phase contract
151
- (govern → direct → look → detect → analyze → validate → close):
152
- plan [--playbook id]... List playbooks + directives, grouped by scope.
153
- [--scope system|code|service|cross-cutting|all]
154
- [--flat] [--mode m] [--session-id id] [--pretty]
155
- govern <playbook> Phase 1: GRC context (jurisdictions, theater,
156
- framework gaps, skill_preload).
157
- [--directive id] [--mode m] [--air-gap]
158
- direct <playbook> Phase 2: scope (threat_context, rwep_threshold,
159
- skill_chain, token_budget).
160
- [--directive id]
161
- look <playbook> Phase 3: artifact-collection spec the host AI
162
- should execute.
163
- [--directive id] [--air-gap]
164
- run [playbook] Phases 4-7: detect → analyze → validate → close.
165
- Three invocation modes:
166
- run <playbook> single playbook (explicit)
167
- run --scope <type> run all playbooks of that scope
168
- run --all run every playbook
169
- run auto-detect from cwd:
170
- .git/ → code
171
- /proc + os-release → system
172
- [--directive id] [--evidence file|-]
173
- [--session-id id] [--session-key hex]
174
- [--force-stale] [--air-gap]
175
- ingest Alias for 'run' matching AGENTS.md terminology.
176
- [--domain id] [--directive id] [--evidence f|-]
177
- reattest <session-id> Re-run prior attestation, diff evidence_hash,
178
- report unchanged | drifted | resolved.
179
-
180
- Output flags (playbook verbs): default JSON one-line; --pretty for indented.
181
-
182
- Common:
183
- help This help.
237
+ framework-gap <fw> <ref> Programmatic gap analysis (one framework, one CVE/scenario).
238
+ path Absolute path to the installed package.
184
239
  version Package version.
185
240
 
241
+ refresh [args] Refresh upstream catalogs + indexes. Replaces
242
+ prefetch + refresh + build-indexes.
243
+
244
+ v0.10.x compatibility (will be removed in v0.12)
245
+ ────────────────────────────────────────────────
246
+
247
+ These verbs still work but emit a one-time deprecation banner. Migrate to
248
+ the v0.11.0 verb shown:
249
+
250
+ plan → brief --all govern → brief <pb> --phase govern
251
+ direct → brief <pb> --phase direct look → brief <pb> --phase look
252
+ ingest → run reattest → attest diff
253
+ list-attestations → attest list scan → discover --scan-only
254
+ dispatch → discover currency → doctor --currency
255
+ verify → doctor --signatures validate-cves → doctor --cves
256
+ validate-rfcs → doctor --rfcs watchlist → watch
257
+ prefetch → refresh --no-network build-indexes → refresh --indexes-only
258
+
259
+ Output: default human-readable (v0.11.0). --json for machine output.
260
+ --pretty for indented JSON.
261
+
186
262
  Examples:
187
- npx @blamejs/exceptd-skills path
188
- npx @blamejs/exceptd-skills prefetch
189
- npx @blamejs/exceptd-skills validate-cves --from-cache --no-fail
190
- npx @blamejs/exceptd-skills skill kernel-lpe-triage
263
+ exceptd discover # what's in this dir?
264
+ exceptd brief secrets --pretty # what does secrets check?
265
+ exceptd run secrets --evidence ev.json --ci # run + CI gate
266
+ exceptd attest list --playbook secrets # prior attestations
267
+ exceptd attest verify <session-id> # tamper check
268
+ exceptd ci --scope code --max-rwep 70 # gate every code playbook
269
+ exceptd ask "I think someone replaced npm packages" # natural-language route
191
270
 
192
271
  Full documentation: ${PKG_ROOT}/README.md
193
272
  Project rules: ${PKG_ROOT}/AGENTS.md
@@ -197,7 +276,7 @@ Project rules: ${PKG_ROOT}/AGENTS.md
197
276
  function main() {
198
277
  const argv = process.argv.slice(2);
199
278
  if (argv.length === 0) {
200
- printHelp();
279
+ printWelcome();
201
280
  process.exit(0);
202
281
  }
203
282
  const cmd = argv[0];
@@ -219,13 +298,26 @@ function main() {
219
298
  // Seven-phase playbook verbs run in-process — they emit JSON to stdout
220
299
  // rather than dispatch to a script.
221
300
  if (PLAYBOOK_VERBS.has(cmd)) {
301
+ // One-time deprecation banner per process when a legacy verb is invoked.
302
+ if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
303
+ process.stderr.write(
304
+ `[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (v0.11.0). ` +
305
+ `Legacy verbs remain functional through this release; they will be removed in v0.12. ` +
306
+ `Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
307
+ );
308
+ process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
309
+ }
222
310
  dispatchPlaybook(cmd, rest);
223
311
  return;
224
312
  }
225
313
 
226
314
  const resolver = COMMANDS[cmd];
227
315
  if (typeof resolver !== "function") {
228
- process.stderr.write(`exceptd: unknown command "${cmd}". Run \`exceptd help\` for the list.\n`);
316
+ // Emit a structured JSON error matching the seven-phase verbs so operators
317
+ // piping through `jq` get one consistent shape across the CLI surface.
318
+ // Plain-text "unknown command" still reaches stderr for human readers.
319
+ const err = { ok: false, error: `unknown command "${cmd}"`, hint: "Run `exceptd help` for the list of verbs.", verb: cmd };
320
+ process.stderr.write(JSON.stringify(err) + "\n");
229
321
  process.exit(2);
230
322
  }
231
323
 
@@ -335,8 +427,10 @@ function dispatchPlaybook(cmd, argv) {
335
427
  }
336
428
 
337
429
  const args = parseArgs(argv, {
338
- bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives", "ci", "latest", "diff-from-latest"],
339
- multi: ["playbook"],
430
+ bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
431
+ "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
432
+ "force-overwrite", "no-stream", "block-on-jurisdiction-clock"],
433
+ multi: ["playbook", "format"],
340
434
  });
341
435
  const pretty = !!args.pretty;
342
436
  const runOpts = {
@@ -344,8 +438,37 @@ function dispatchPlaybook(cmd, argv) {
344
438
  forceStale: !!args["force-stale"],
345
439
  };
346
440
  if (args["session-id"]) runOpts.session_id = args["session-id"];
347
- if (args["session-key"]) runOpts.session_key = args["session-key"];
348
- if (args.mode) runOpts.mode = args.mode;
441
+ if (args["attestation-root"]) runOpts.attestationRoot = args["attestation-root"];
442
+ if (args["session-key"]) {
443
+ // Bug #33: validate that --session-key is hex. Previously any string was
444
+ // silently accepted; HMAC signing then either failed silently or produced
445
+ // an unverifiable signature.
446
+ if (!/^[0-9a-fA-F]+$/.test(args["session-key"])) {
447
+ return emitError("run: --session-key must be hex characters only (0-9, a-f). Generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", { provided_length: args["session-key"].length }, pretty);
448
+ }
449
+ if (args["session-key"].length < 16) {
450
+ return emitError("run: --session-key is too short (need at least 16 hex chars / 64 bits of entropy).", { provided_length: args["session-key"].length }, pretty);
451
+ }
452
+ runOpts.session_key = args["session-key"];
453
+ }
454
+ if (args.mode) {
455
+ // Bug #32: validate --mode against the accepted set. Previously
456
+ // `--mode garbage` was silently accepted.
457
+ const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
458
+ if (!VALID_MODES.includes(args.mode)) {
459
+ return emitError(`run: --mode "${args.mode}" not in accepted set ${JSON.stringify(VALID_MODES)}.`, { provided: args.mode }, pretty);
460
+ }
461
+ runOpts.mode = args.mode;
462
+ }
463
+ // Multi-operator teams need attestations bound to a specific human or
464
+ // service identity. --operator <name> persists into the attestation file
465
+ // for audit-trail accountability. Free-form string; no validation.
466
+ if (args.operator) runOpts.operator = args.operator;
467
+ // --ack: operator acknowledges the jurisdiction obligations surfaced by
468
+ // govern. Captured in attestation; downstream tooling can check whether
469
+ // consent was explicit vs. implicit. AGENTS.md says the AI should surface
470
+ // and wait for ack — this is how the ack gets recorded.
471
+ if (args.ack) runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
349
472
 
350
473
  let runner;
351
474
  try {
@@ -365,6 +488,16 @@ function dispatchPlaybook(cmd, argv) {
365
488
  case "ingest": return cmdIngest(runner, args, runOpts, pretty);
366
489
  case "reattest": return cmdReattest(runner, args, runOpts, pretty);
367
490
  case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
491
+ case "attest": return cmdAttest(runner, args, runOpts, pretty);
492
+ case "brief": return cmdBrief(runner, args, runOpts, pretty);
493
+ case "run-all": return cmdRunAll(runner, args, runOpts, pretty);
494
+ case "verify-attestation": return cmdVerifyAttestation(runner, args, runOpts, pretty);
495
+ case "lint": return cmdLint(runner, args, runOpts, pretty);
496
+ case "discover": return cmdDiscover(runner, args, runOpts, pretty);
497
+ case "doctor": return cmdDoctor(runner, args, runOpts, pretty);
498
+ case "ai-run": return cmdAiRun(runner, args, runOpts, pretty);
499
+ case "ask": return cmdAsk(runner, args, runOpts, pretty);
500
+ case "ci": return cmdCi(runner, args, runOpts, pretty);
368
501
  }
369
502
  } catch (e) {
370
503
  emitError(e.message, { verb: cmd }, pretty);
@@ -429,11 +562,27 @@ Flags:
429
562
  { artifacts, signal_overrides, signals, precondition_checks }
430
563
  Multi-playbook shape:
431
564
  { "<playbook_id>": { artifacts, ... }, ... }
565
+ --evidence-dir <dir> Read <playbook-id>.json files from a directory and
566
+ merge into the multi-run bundle. Cron-friendly.
432
567
  --vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
433
568
  not_affected | resolved | false_positive (CycloneDX)
434
569
  or not_affected | fixed (OpenVEX) drop out of
435
570
  analyze.matched_cves. The disposition is preserved
436
571
  under analyze.vex.dropped_cves.
572
+ --format <fmt> ... Emit the close.evidence_package bundle in additional
573
+ formats. Repeatable. Supported: csaf-2.0 | sarif |
574
+ openvex | markdown. CSAF is always primary; extras
575
+ populate close.evidence_package.bundles_by_format.
576
+ --explain Dry-run: emit preconditions, required artifacts,
577
+ recognized signal keys, and a submission skeleton.
578
+ Does not run detect/analyze/validate/close.
579
+ --signal-list Emit only the signal_overrides keys the detect phase
580
+ recognizes (lighter than --explain).
581
+ --operator <name> Bind the attestation to a specific human/service
582
+ identity. Persisted under attestation.operator.
583
+ --ack Mark explicit operator consent to the jurisdiction
584
+ obligations surfaced by govern. Persisted under
585
+ attestation.operator_consent.
437
586
  --diff-from-latest Compare evidence_hash against the most recent prior
438
587
  attestation for the same playbook in
439
588
  .exceptd/attestations/. Emits status: unchanged | drifted.
@@ -474,10 +623,273 @@ Args / flags:
474
623
 
475
624
  Lists every attestation under .exceptd/attestations/<session_id>/, sorted
476
625
  newest-first, with truncated evidence_hash + capture timestamp + file path.`,
626
+ attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
627
+
628
+ Subverbs:
629
+ attest show <sid> Emit the full (unredacted) attestation.
630
+ attest export <sid> Emit redacted JSON suitable for audit submission.
631
+ Strips raw artifact values; preserves evidence_hash,
632
+ signature, classification, RWEP, remediation choice.
633
+ --format csaf wraps the export in a CSAF envelope.
634
+ attest verify <sid> Verify .sig sidecar against keys/public.pem.
635
+ Reports tamper status per attestation file.
636
+
637
+ All subverbs honor --pretty for indented JSON output.`,
638
+ discover: `discover — context-aware playbook recommender (v0.11.0).
639
+
640
+ Replaces: scan + dispatch + recommend.
641
+
642
+ Sniffs the cwd (.git/, package.json, pyproject.toml, requirements.txt,
643
+ Cargo.toml, go.mod, Dockerfile, docker-compose.yml, *.tf, k8s/, .env) and
644
+ on Linux reads /etc/os-release to detect host distro. Emits a list of
645
+ recommended exceptd playbooks tailored to what was found.
646
+
647
+ Flags:
648
+ --scan-only Also include legacy \`scan\` output under legacy_scan.
649
+ --json Emit JSON (default is human-readable text).
650
+ --pretty Indented JSON output (implies --json).
651
+
652
+ Output: context + recommended_playbooks[] + next_steps[].`,
653
+ doctor: `doctor — one-shot health check (v0.11.0).
654
+
655
+ Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
656
+
657
+ Subchecks:
658
+ --signatures Ed25519 signature verification across all skills.
659
+ --currency Skill currency report (last_threat_review).
660
+ --cves CVE catalog validation (offline view).
661
+ --rfcs RFC catalog validation (offline view).
662
+ (no flag) All four, plus signing-status (private key presence).
663
+
664
+ Flags:
665
+ --json Emit JSON (default is human-readable text).
666
+ --pretty Indented JSON output (implies --json).
667
+
668
+ Output: checks{} per subcheck + summary{all_green, issues_count}.`,
669
+ "ai-run": `ai-run <playbook> — streaming JSONL contract for AI-driven runs (v0.11.0).
670
+
671
+ Emits one JSON event per line as the seven phases progress, and reads
672
+ evidence events back on stdin. Single pipe instead of brief → look → run.
673
+
674
+ Flags:
675
+ <playbook> Required positional.
676
+ --directive <id> Specific directive (default: first one).
677
+ --no-stream Single-shot mode: emit all phases as one JSON doc
678
+ without reading stdin (uses runner.run directly).
679
+ --pretty Indented JSON output (single-shot only).
680
+
681
+ Stdin event grammar (one JSON object per line):
682
+ {"event":"evidence","payload":{"observations":{},"verdict":{}}}
683
+
684
+ Emits phases: govern → direct → look → await_evidence → detect → analyze
685
+ → validate → close, then {"event":"done","ok":true,"session_id":"..."}.
686
+ Errors emit {"event":"error","reason":"..."} and exit non-zero.`,
687
+ ask: `ask "<plain-English question>" — keyword routing to playbooks (v0.11.0).
688
+
689
+ Tokenises the question (words > 3 chars), scores every playbook by overlap
690
+ against domain.name + domain.attack_class + the first sentence of
691
+ phases.direct.threat_context, returns the top 5 matches with a confidence
692
+ score.
693
+
694
+ Args / flags:
695
+ "<question>" Plain-English question. Wrap in quotes.
696
+ --pretty Indented JSON output.
697
+
698
+ Output: { verb, question, routed_to:[ids], confidence, next_step,
699
+ full_match_list }. Empty match list when no token overlap — surfaces a
700
+ hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.`,
701
+ ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
702
+
703
+ Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
704
+ exit-code contract designed for one-line .github/workflows entries.
705
+
706
+ Flags:
707
+ --all Run every playbook.
708
+ --scope <type> Filter: system | code | service | cross-cutting.
709
+ (no flag) Auto-detect scopes from cwd (same logic as run).
710
+ --evidence <file> Submission bundle (multi-playbook shape).
711
+ --evidence-dir <dir> Read <playbook-id>.json files from a directory.
712
+ --max-rwep <int> Override RWEP escalate threshold (default: per-playbook).
713
+ --block-on-jurisdiction-clock
714
+ Fail when any close.notification_actions started a
715
+ regulatory clock (GDPR 72h, HIPAA breach, etc.).
716
+ --pretty Indented JSON output.
717
+
718
+ Exit codes: 0 PASS, 2 FAIL (detected | rwep ≥ cap | clock started w/ block flag).
719
+ Output: verb, session_id, playbooks_run, summary{total, detected,
720
+ max_rwep_observed, jurisdiction_clocks_started, verdict}, results[].`,
477
721
  };
478
722
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
479
723
  }
480
724
 
725
+ /**
726
+ * `brief` — collapses plan + govern + direct + look into one informational
727
+ * document. Phases 1-3 of the seven-phase contract are entirely informational
728
+ * (no state mutation), so the AI reads ONE document instead of three CLI
729
+ * round-trips.
730
+ *
731
+ * Modes:
732
+ * brief <playbook> → one playbook, all three info phases unified
733
+ * brief --all → every playbook (replaces `plan`)
734
+ * brief <playbook> --phase <name>
735
+ * → emit only the named phase (compat with
736
+ * legacy `govern`/`direct`/`look` callers)
737
+ */
738
+ /**
739
+ * `lint <playbook> <evidence-file>` — pre-flight check the submission shape
740
+ * against the playbook's expected indicators / preconditions / artifacts
741
+ * WITHOUT executing detect/analyze/validate/close. Lets the AI iterate on
742
+ * its evidence JSON before going through phases 4-7. Returns a categorized
743
+ * list: ok / missing_required / unknown_keys / type_mismatch / suggestions.
744
+ */
745
+ function cmdLint(runner, args, runOpts, pretty) {
746
+ const playbookId = args._[0];
747
+ const evidencePath = args._[1] || args.evidence;
748
+ if (!playbookId || !evidencePath) {
749
+ return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
750
+ }
751
+ let pb;
752
+ try { pb = runner.loadPlaybook(playbookId); }
753
+ catch (e) { return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty); }
754
+
755
+ let submission;
756
+ try { submission = readEvidence(evidencePath); }
757
+ catch (e) { return emitError(`lint: failed to read evidence: ${e.message}`, { evidence: evidencePath }, pretty); }
758
+
759
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
760
+ const resolved = runner._resolvedPhase;
761
+ const lookPhase = pb.phases?.look || {};
762
+ const detectPhase = pb.phases?.detect || {};
763
+
764
+ const requiredArtifacts = (lookPhase.artifacts || []).filter(a => a.required).map(a => a.id);
765
+ const knownArtifacts = new Set((lookPhase.artifacts || []).map(a => a.id));
766
+ const knownIndicators = new Set((detectPhase.indicators || []).map(i => i.id));
767
+ const knownPreconditions = new Set((pb._meta?.preconditions || []).map(p => p.id));
768
+
769
+ // Support both flat (observations) and nested (artifacts/signal_overrides) shapes.
770
+ const flat = submission.observations || null;
771
+ const artifactsKey = flat ? flat : (submission.artifacts || {});
772
+ const signalsKey = flat ? flat : (submission.signal_overrides || {});
773
+
774
+ const missingRequired = requiredArtifacts.filter(id => {
775
+ const a = artifactsKey[id];
776
+ return !a || (flat ? !a.captured : !a.captured);
777
+ });
778
+
779
+ const unknownArtifactKeys = Object.keys(submission.artifacts || {})
780
+ .filter(k => !knownArtifacts.has(k));
781
+ const unknownSignalKeys = Object.keys(submission.signal_overrides || {})
782
+ .filter(k => !knownIndicators.has(k));
783
+ const unknownObservationKeys = flat
784
+ ? Object.keys(flat).filter(k => !knownArtifacts.has(k) && !knownIndicators.has(k) && !knownPreconditions.has(k))
785
+ : [];
786
+
787
+ const unsuppliedPreconditions = [...knownPreconditions].filter(
788
+ p => !((submission.precondition_checks || {}).hasOwnProperty(p) || (flat || {}).hasOwnProperty(p))
789
+ );
790
+
791
+ const issues = [];
792
+ for (const id of missingRequired) {
793
+ issues.push({ severity: "error", kind: "missing_required_artifact", artifact_id: id, hint: `Add to submission.artifacts.${id} = { value, captured: true } (or under observations in the flat shape).` });
794
+ }
795
+ for (const k of unknownArtifactKeys) {
796
+ issues.push({ severity: "warn", kind: "unknown_artifact_key", key: k, hint: `Not in playbook ${playbookId} look.artifacts[]. Recognized: ${[...knownArtifacts].slice(0, 10).join(", ")}…` });
797
+ }
798
+ for (const k of unknownSignalKeys) {
799
+ issues.push({ severity: "warn", kind: "unknown_signal_override_key", key: k, hint: `Not in playbook ${playbookId} detect.indicators[]. Run \`exceptd run ${playbookId} --signal-list\` to enumerate.` });
800
+ }
801
+ for (const p of unsuppliedPreconditions) {
802
+ issues.push({ severity: "info", kind: "precondition_unverified", precondition_id: p, hint: `Add submission.precondition_checks.${p} = true|false (or under observations in the flat shape).` });
803
+ }
804
+ for (const k of unknownObservationKeys) {
805
+ issues.push({ severity: "warn", kind: "unknown_observation_key", key: k });
806
+ }
807
+
808
+ const ok = issues.every(i => i.severity !== "error");
809
+ emit({
810
+ verb: "lint",
811
+ ok,
812
+ playbook_id: playbookId,
813
+ directive_id: directiveId,
814
+ submission_shape: flat ? "flat (v0.11.0)" : "nested (v0.10.x)",
815
+ summary: {
816
+ errors: issues.filter(i => i.severity === "error").length,
817
+ warnings: issues.filter(i => i.severity === "warn").length,
818
+ info: issues.filter(i => i.severity === "info").length,
819
+ },
820
+ issues,
821
+ }, pretty);
822
+ if (!ok) process.exitCode = 1;
823
+ }
824
+
825
+ function cmdBrief(runner, args, runOpts, pretty) {
826
+ const playbookId = args._[0];
827
+ const onlyPhase = args.phase || null;
828
+
829
+ if (!playbookId || args.all) {
830
+ // Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
831
+ return cmdPlan(runner, args, runOpts, pretty);
832
+ }
833
+
834
+ const pb = runner.loadPlaybook(playbookId);
835
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
836
+
837
+ const govern = runner.govern(playbookId, directiveId, runOpts);
838
+ const direct = runner.direct(playbookId, directiveId);
839
+ const look = runner.look(playbookId, directiveId, runOpts);
840
+
841
+ // If --phase was passed, emit only that phase to ease legacy migration.
842
+ if (onlyPhase === "govern") return emit(govern, pretty);
843
+ if (onlyPhase === "direct") return emit(direct, pretty);
844
+ if (onlyPhase === "look") return emit(look, pretty);
845
+
846
+ emit({
847
+ verb: "brief",
848
+ playbook_id: playbookId,
849
+ directive_id: directiveId,
850
+ scope: pb._meta?.scope || null,
851
+ threat_currency_score: pb._meta?.threat_currency_score,
852
+
853
+ // From govern phase:
854
+ jurisdiction_obligations: govern.jurisdiction_obligations,
855
+ theater_fingerprints: govern.theater_fingerprints,
856
+ framework_context: govern.framework_context,
857
+ skill_preload: govern.skill_preload,
858
+
859
+ // From direct phase:
860
+ threat_context: direct.threat_context,
861
+ rwep_threshold: direct.rwep_threshold,
862
+ framework_lag_declaration: direct.framework_lag_declaration,
863
+ skill_chain: direct.skill_chain,
864
+ token_budget: direct.token_budget,
865
+
866
+ // From look phase:
867
+ preconditions: look.preconditions,
868
+ precondition_submission_shape: look.precondition_submission_shape,
869
+ artifacts: look.artifacts,
870
+ collection_scope: look.collection_scope,
871
+ environment_assumptions: look.environment_assumptions,
872
+ fallback_if_unavailable: look.fallback_if_unavailable,
873
+
874
+ // Forward references — what the AI will see during run:
875
+ detect_indicators_preview: (pb.phases?.detect?.indicators || []).map(i => ({
876
+ id: i.id, type: i.type, confidence: i.confidence, deterministic: !!i.deterministic
877
+ })),
878
+ }, pretty);
879
+ }
880
+
881
+ /** `run-all` alias for `run --all`. */
882
+ function cmdRunAll(runner, args, runOpts, pretty) {
883
+ args.all = true;
884
+ return cmdRun(runner, args, runOpts, pretty);
885
+ }
886
+
887
+ /** `verify-attestation <sid>` alias for `attest verify <sid>`. */
888
+ function cmdVerifyAttestation(runner, args, runOpts, pretty) {
889
+ args._ = ["verify", ...(args._ || [])];
890
+ return cmdAttest(runner, args, runOpts, pretty);
891
+ }
892
+
481
893
  function cmdPlan(runner, args, runOpts, pretty) {
482
894
  let playbookIds = args.playbook
483
895
  ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
@@ -498,13 +910,27 @@ function cmdPlan(runner, args, runOpts, pretty) {
498
910
  Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
499
911
  );
500
912
  }
501
- // --directives expands each playbook entry with its directive id + title +
502
- // applies_to so operators / AIs can pick a specific directive without
503
- // grepping playbook source.
913
+ // --directives expands each playbook entry with directive id + title +
914
+ // applies_to + description. v0.10.3-aware fallback: pull description from
915
+ // (a) explicit d.description, (b) directive override threat_context,
916
+ // (c) playbook-level direct.threat_context first sentence, (d) playbook
917
+ // domain.name. Operators need operator-facing prose, not just an ID + enum.
504
918
  if (args.directives) {
505
919
  for (const pb of plan.playbooks) {
506
920
  const full = runner.loadPlaybook(pb.id);
507
- pb.directives = full.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }));
921
+ const baseDirect = full.phases?.direct || {};
922
+ pb.directives = full.directives.map(d => {
923
+ const overrideDirect = d.phase_overrides?.direct || {};
924
+ const threatContext = overrideDirect.threat_context || baseDirect.threat_context || null;
925
+ const firstSentence = threatContext ? (threatContext.split(/(?<=[.!?])\s+/)[0] || "").slice(0, 240) : null;
926
+ return {
927
+ id: d.id,
928
+ title: d.title,
929
+ description: d.description || firstSentence || full.domain?.name || null,
930
+ applies_to: d.applies_to,
931
+ threat_context_preview: firstSentence,
932
+ };
933
+ });
508
934
  }
509
935
  }
510
936
  emit(plan, pretty);
@@ -606,6 +1032,53 @@ function cmdRun(runner, args, runOpts, pretty) {
606
1032
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
607
1033
  if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
608
1034
 
1035
+ // --explain: dry-run that emits the preconditions + artifacts + indicators
1036
+ // + signal keys the agent would need to supply, WITHOUT running detect/
1037
+ // analyze/validate/close. Lets operators preview before assembling evidence.
1038
+ if (args.explain) {
1039
+ const lookPhase = runner.look(playbookId, directiveId, runOpts);
1040
+ const detectPhase = runner.loadPlaybook(playbookId).phases?.detect || {};
1041
+ const detectResolved = runner._resolvedPhase ? runner._resolvedPhase(pb, directiveId, "detect") : detectPhase;
1042
+ emit({
1043
+ verb: "run",
1044
+ mode: "explain",
1045
+ playbook_id: playbookId,
1046
+ directive_id: directiveId,
1047
+ scope: pb._meta?.scope || null,
1048
+ preconditions: lookPhase.preconditions,
1049
+ precondition_submission_shape: lookPhase.precondition_submission_shape,
1050
+ artifacts_required: lookPhase.artifacts.filter(a => a.required).map(a => ({ id: a.id, type: a.type, source: a.source })),
1051
+ artifacts_optional: lookPhase.artifacts.filter(a => !a.required).map(a => ({ id: a.id, type: a.type, source: a.source, fallback: lookPhase.fallback_if_unavailable.find(f => f.artifact_id === a.id) })),
1052
+ signal_keys: (detectResolved.indicators || []).map(i => ({ id: i.id, type: i.type, deterministic: !!i.deterministic, confidence: i.confidence })),
1053
+ detect_classification_override: { hint: "submit signals.detection_classification = 'detected' | 'inconclusive' | 'not_detected' | 'clean' to override engine-computed classification.", valid_values: ["detected", "inconclusive", "not_detected", "clean"] },
1054
+ submission_skeleton: {
1055
+ artifacts: Object.fromEntries(lookPhase.artifacts.map(a => [a.id, { value: "<your captured output>", captured: true }])),
1056
+ signal_overrides: Object.fromEntries((detectResolved.indicators || []).map(i => [i.id, "hit | miss | inconclusive"])),
1057
+ signals: { detection_classification: "<one of: detected|inconclusive|not_detected|clean>", theater_verdict: "<clear | theater | pending_agent_run>" },
1058
+ precondition_checks: Object.fromEntries(lookPhase.preconditions.map(p => [p.id, true])),
1059
+ }
1060
+ }, pretty);
1061
+ return;
1062
+ }
1063
+
1064
+ // --signal-list: enumerate every signal_overrides key the detect phase
1065
+ // recognizes. Lighter than --explain.
1066
+ if (args["signal-list"]) {
1067
+ const detectResolved = runner._resolvedPhase
1068
+ ? runner._resolvedPhase(pb, directiveId, "detect")
1069
+ : pb.phases?.detect;
1070
+ emit({
1071
+ verb: "run",
1072
+ mode: "signal-list",
1073
+ playbook_id: playbookId,
1074
+ directive_id: directiveId,
1075
+ signal_overrides_keys: (detectResolved?.indicators || []).map(i => i.id),
1076
+ signal_value_grammar: "hit | miss | inconclusive",
1077
+ detection_classification_override_keys: ["detected", "inconclusive", "not_detected", "clean"],
1078
+ }, pretty);
1079
+ return;
1080
+ }
1081
+
609
1082
  let submission = {};
610
1083
  if (args.evidence) {
611
1084
  try {
@@ -621,6 +1094,15 @@ function cmdRun(runner, args, runOpts, pretty) {
621
1094
  runOpts.precondition_checks = submission.precondition_checks;
622
1095
  }
623
1096
 
1097
+ // --format <fmt>: override the playbook's declared evidence_package.bundle_format.
1098
+ // Supports csaf-2.0 | sarif | openvex | markdown. Multiple --format flags
1099
+ // produce multiple bundles in the close response under bundles_by_format.
1100
+ if (args.format) {
1101
+ const formats = Array.isArray(args.format) ? args.format : [args.format];
1102
+ submission.signals = submission.signals || {};
1103
+ submission.signals._bundle_formats = formats;
1104
+ }
1105
+
624
1106
  // --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
625
1107
  // CVE ID set through to analyze() so matched_cves drops them.
626
1108
  if (args.vex) {
@@ -638,22 +1120,41 @@ function cmdRun(runner, args, runOpts, pretty) {
638
1120
 
639
1121
  // Persist attestation for reattest cycles when the run succeeded.
640
1122
  if (result && result.ok && result.session_id) {
641
- try {
642
- const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
643
- fs.mkdirSync(dir, { recursive: true });
644
- fs.writeFileSync(
645
- path.join(dir, "attestation.json"),
646
- JSON.stringify({
647
- session_id: result.session_id,
648
- playbook_id: result.playbook_id,
649
- directive_id: result.directive_id,
650
- evidence_hash: result.evidence_hash,
651
- submission,
652
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
653
- captured_at: new Date().toISOString(),
654
- }, null, 2)
655
- );
656
- } catch { /* non-fatalattestation persistence is best-effort */ }
1123
+ const persistResult = persistAttestation({
1124
+ sessionId: result.session_id,
1125
+ playbookId: result.playbook_id,
1126
+ directiveId: result.directive_id,
1127
+ evidenceHash: result.evidence_hash,
1128
+ operator: runOpts.operator,
1129
+ operatorConsent: runOpts.operator_consent,
1130
+ submission,
1131
+ runOpts,
1132
+ forceOverwrite: !!args["force-overwrite"],
1133
+ filename: "attestation.json",
1134
+ });
1135
+ if (!persistResult.ok) {
1136
+ // Session-id collision without --force-overwrite. Refuse, surface the
1137
+ // existing path so the operator can decide, and emit JSON to stderr
1138
+ // matching the unified error shape. Exit non-zeroa silent overwrite
1139
+ // is a tamper-evidence violation.
1140
+ const err = {
1141
+ ok: false,
1142
+ error: persistResult.error,
1143
+ existing_attestation: persistResult.existingPath,
1144
+ hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
1145
+ verb: "run",
1146
+ };
1147
+ process.stderr.write(JSON.stringify(err) + "\n");
1148
+ process.exit(3);
1149
+ }
1150
+ if (persistResult.prior_session_id) {
1151
+ // Force-overwrite happened — surface the prior_session_id in the
1152
+ // returned result so the operator/AI can see what the new attestation
1153
+ // replaced and link back via the prior_session_id field persisted on
1154
+ // disk.
1155
+ result.prior_session_id = persistResult.prior_session_id;
1156
+ result.overwrote_at = persistResult.overwrote_at;
1157
+ }
657
1158
  }
658
1159
 
659
1160
  if (result && result.ok === false) {
@@ -746,6 +1247,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
746
1247
  return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
747
1248
  }
748
1249
  }
1250
+ // --evidence-dir <dir>: each <playbook-id>.json under the directory is read
1251
+ // as that playbook's submission. Lets operators wire up one cron job that
1252
+ // collects per-playbook evidence into a directory, then runs the whole
1253
+ // contract in one pass.
1254
+ if (args["evidence-dir"]) {
1255
+ const dir = args["evidence-dir"];
1256
+ if (!fs.existsSync(dir)) {
1257
+ return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
1258
+ }
1259
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
1260
+ const pbId = f.replace(/\.json$/, "");
1261
+ try {
1262
+ bundle[pbId] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
1263
+ } catch (e) {
1264
+ return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
1265
+ }
1266
+ }
1267
+ }
749
1268
 
750
1269
  const results = [];
751
1270
  for (const id of ids) {
@@ -763,22 +1282,26 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
763
1282
 
764
1283
  // Persist per-playbook attestation under the shared session.
765
1284
  if (result && result.ok) {
766
- try {
767
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
768
- fs.mkdirSync(dir, { recursive: true });
769
- fs.writeFileSync(
770
- path.join(dir, `${id}.json`),
771
- JSON.stringify({
772
- session_id: sessionId,
773
- playbook_id: id,
774
- directive_id: directiveId,
775
- evidence_hash: result.evidence_hash,
776
- submission,
777
- run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
778
- captured_at: new Date().toISOString(),
779
- }, null, 2)
780
- );
781
- } catch { /* non-fatal */ }
1285
+ const persisted = persistAttestation({
1286
+ sessionId,
1287
+ playbookId: id,
1288
+ directiveId,
1289
+ evidenceHash: result.evidence_hash,
1290
+ operator: perRunOpts.operator,
1291
+ operatorConsent: perRunOpts.operator_consent,
1292
+ submission,
1293
+ runOpts: perRunOpts,
1294
+ forceOverwrite: !!args["force-overwrite"],
1295
+ filename: `${id}.json`,
1296
+ });
1297
+ if (!persisted.ok) {
1298
+ // Multi-run collision: don't abort the whole bundle; surface in the
1299
+ // per-playbook result so the operator can see exactly which
1300
+ // playbook's attestation refused to overwrite.
1301
+ result.attestation_persist = { ok: false, error: persisted.error };
1302
+ } else if (persisted.prior_session_id) {
1303
+ result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
1304
+ }
782
1305
  }
783
1306
  results.push(result);
784
1307
  }
@@ -834,7 +1357,7 @@ function cmdIngest(runner, args, runOpts, pretty) {
834
1357
 
835
1358
  if (result && result.ok && result.session_id) {
836
1359
  try {
837
- const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
1360
+ const dir = path.join(resolveAttestationRoot(runOpts), result.session_id);
838
1361
  fs.mkdirSync(dir, { recursive: true });
839
1362
  fs.writeFileSync(
840
1363
  path.join(dir, "attestation.json"),
@@ -858,21 +1381,211 @@ function cmdIngest(runner, args, runOpts, pretty) {
858
1381
  emit(result, pretty);
859
1382
  }
860
1383
 
1384
+ /**
1385
+ * Resolve the attestation root for a given run. Resolution order (most-specific
1386
+ * first):
1387
+ * 1. --attestation-root <path> explicit caller override
1388
+ * 2. EXCEPTD_HOME env var operator-level configuration
1389
+ * 3. ~/.exceptd/attestations/<repo-or-host-tag>/ default (v0.11.0+)
1390
+ * 4. .exceptd/attestations/ legacy cwd-relative fallback when ~/.exceptd
1391
+ * can't be created (read-only home / sandbox)
1392
+ *
1393
+ * Repo tag is derived from `git config --get remote.origin.url` + branch when
1394
+ * available, else a hostname tag. This means `attest list` works regardless of
1395
+ * which directory you happened to run from. Operators can override via env.
1396
+ */
1397
+ function resolveAttestationRoot(runOpts) {
1398
+ if (runOpts && runOpts.attestationRoot) return runOpts.attestationRoot;
1399
+ if (process.env.EXCEPTD_HOME) return path.join(process.env.EXCEPTD_HOME, "attestations");
1400
+ const home = require("os").homedir();
1401
+ if (!home) return path.join(process.cwd(), ".exceptd", "attestations");
1402
+ const root = path.join(home, ".exceptd", "attestations", deriveRunTag());
1403
+ try {
1404
+ fs.mkdirSync(root, { recursive: true });
1405
+ return root;
1406
+ } catch {
1407
+ return path.join(process.cwd(), ".exceptd", "attestations");
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * Derive a stable tag for attestations: `<repo-name>@<branch>` when in a git
1413
+ * repo, else `host:<hostname>`. Used as the per-context directory under
1414
+ * ~/.exceptd/attestations/ so multi-repo operators don't conflate sessions.
1415
+ */
1416
+ function deriveRunTag() {
1417
+ const { spawnSync } = require("child_process");
1418
+ try {
1419
+ const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" });
1420
+ if (remote.status === 0 && remote.stdout.trim()) {
1421
+ const url = remote.stdout.trim();
1422
+ const repoName = (url.match(/[\/:]([^/]+?)(?:\.git)?$/) || [, "unknown"])[1];
1423
+ const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
1424
+ const branchName = branch.status === 0 ? branch.stdout.trim() : "head";
1425
+ return `${repoName}@${branchName}`.replace(/[^A-Za-z0-9._@-]/g, "_");
1426
+ }
1427
+ } catch {}
1428
+ return `host:${require("os").hostname()}`.replace(/[^A-Za-z0-9._@:-]/g, "_");
1429
+ }
1430
+
1431
+ /**
1432
+ * Persist an attestation file. Refuses to overwrite an existing file unless
1433
+ * `forceOverwrite` is true. When force-overwriting, the new attestation
1434
+ * records `prior_session_id` (== current session_id; the prior content is
1435
+ * what's being replaced) plus a `prior_evidence_hash` link extracted from
1436
+ * the file on disk before clobbering — so the audit-trail chain survives.
1437
+ *
1438
+ * Returns { ok: true, prior_session_id?, overwrote_at?, persist_path } on
1439
+ * success; or { ok: false, error, existingPath } when the operator hit a
1440
+ * collision without --force-overwrite.
1441
+ */
1442
+ function persistAttestation(args) {
1443
+ const { sessionId, playbookId, directiveId, evidenceHash, operator,
1444
+ operatorConsent, submission, runOpts, forceOverwrite, filename } = args;
1445
+ const root = resolveAttestationRoot(runOpts);
1446
+ const dir = path.join(root, sessionId);
1447
+ const filePath = path.join(dir, filename);
1448
+
1449
+ let prior = null;
1450
+ if (fs.existsSync(filePath)) {
1451
+ try { prior = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
1452
+ if (!forceOverwrite) {
1453
+ return {
1454
+ ok: false,
1455
+ error: `Attestation already exists at ${path.relative(process.cwd(), filePath)}. Session-id collision (${sessionId}) — refusing to overwrite to preserve audit trail.`,
1456
+ existingPath: path.relative(process.cwd(), filePath),
1457
+ };
1458
+ }
1459
+ }
1460
+
1461
+ try {
1462
+ fs.mkdirSync(dir, { recursive: true });
1463
+ const attestation = {
1464
+ session_id: sessionId,
1465
+ playbook_id: playbookId,
1466
+ directive_id: directiveId,
1467
+ evidence_hash: evidenceHash,
1468
+ operator: operator || null,
1469
+ operator_consent: operatorConsent || null,
1470
+ submission,
1471
+ run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
1472
+ captured_at: new Date().toISOString(),
1473
+ // When overwriting (with --force-overwrite), link to the prior content
1474
+ // by evidence_hash + capture timestamp. session_id is the same (that's
1475
+ // why we collided), so it's the hash + timestamp that distinguish.
1476
+ prior_evidence_hash: prior ? (prior.evidence_hash || null) : null,
1477
+ prior_captured_at: prior ? (prior.captured_at || null) : null,
1478
+ };
1479
+ fs.writeFileSync(filePath, JSON.stringify(attestation, null, 2));
1480
+ maybeSignAttestation(filePath);
1481
+ return {
1482
+ ok: true,
1483
+ prior_session_id: prior ? sessionId : null,
1484
+ overwrote_at: prior ? prior.captured_at : null,
1485
+ };
1486
+ } catch (e) {
1487
+ return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
1488
+ }
1489
+ }
1490
+
1491
+ /**
1492
+ * Ed25519-sign an attestation file when .keys/private.pem is available
1493
+ * (matches lib/sign.js convention for skill signing). Writes a sidecar
1494
+ * `<file>.sig` alongside the attestation. Defense against post-hoc tampering
1495
+ * by anyone who can write to .exceptd/.
1496
+ *
1497
+ * Without a private key, writes a marker file documenting the signed=false
1498
+ * state so downstream tooling can distinguish "operator declined signing"
1499
+ * from "the .sig file was deleted by an attacker."
1500
+ */
1501
+ function maybeSignAttestation(filePath) {
1502
+ const crypto = require("crypto");
1503
+ const sigPath = filePath + ".sig";
1504
+ const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
1505
+ const content = fs.readFileSync(filePath, "utf8");
1506
+ // One-time-per-process unsigned warning so cron jobs don't spam stderr.
1507
+ // Operators who set `.keys/private.pem` get tamper-evident attestations;
1508
+ // operators without the keypair get a single nudge per session telling them
1509
+ // exactly how to enable signing.
1510
+ if (!fs.existsSync(privKeyPath) && !process.env.EXCEPTD_UNSIGNED_WARNED) {
1511
+ process.stderr.write(
1512
+ "[attest] attestation will be written UNSIGNED (no private key at .keys/private.pem). " +
1513
+ "Operators reading the attestation later can verify the SHA-256 hash but not authenticity. " +
1514
+ "Enable Ed25519 signing: `node lib/sign.js generate-keypair`. " +
1515
+ "Suppress this notice: export EXCEPTD_UNSIGNED_WARNED=1.\n"
1516
+ );
1517
+ process.env.EXCEPTD_UNSIGNED_WARNED = "1";
1518
+ }
1519
+ try {
1520
+ if (fs.existsSync(privKeyPath)) {
1521
+ const privateKey = fs.readFileSync(privKeyPath, "utf8");
1522
+ const sig = crypto.sign(null, Buffer.from(content, "utf8"), {
1523
+ key: privateKey,
1524
+ dsaEncoding: "ieee-p1363",
1525
+ });
1526
+ fs.writeFileSync(sigPath, JSON.stringify({
1527
+ algorithm: "Ed25519",
1528
+ signature_base64: sig.toString("base64"),
1529
+ signed_at: new Date().toISOString(),
1530
+ signs_path: path.basename(filePath),
1531
+ signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
1532
+ }, null, 2));
1533
+ } else {
1534
+ fs.writeFileSync(sigPath, JSON.stringify({
1535
+ algorithm: "unsigned",
1536
+ signed: false,
1537
+ signed_at: null,
1538
+ signs_path: path.basename(filePath),
1539
+ signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
1540
+ note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
1541
+ }, null, 2));
1542
+ }
1543
+ } catch { /* non-fatal — signing failure shouldn't block the run */ }
1544
+ }
1545
+
1546
+ /**
1547
+ * Resolve a session-id to its on-disk directory. Searches both the v0.11.0
1548
+ * default root and the legacy cwd-relative root; returns whichever exists.
1549
+ * Returns null if neither has the session.
1550
+ */
1551
+ function findSessionDir(sessionId, runOpts) {
1552
+ const candidates = [
1553
+ path.join(resolveAttestationRoot(runOpts), sessionId),
1554
+ path.join(process.cwd(), ".exceptd", "attestations", sessionId),
1555
+ ];
1556
+ for (const c of candidates) if (fs.existsSync(c)) return c;
1557
+ return null;
1558
+ }
1559
+
861
1560
  /**
862
1561
  * Find the latest attestation file under .exceptd/attestations/.
863
1562
  * Filters: optional playbook ID and optional "since" ISO timestamp.
864
1563
  * Returns { sessionId, playbookId, file, parsed } or null.
865
1564
  */
866
1565
  function findLatestAttestation(opts = {}) {
867
- const root = path.join(process.cwd(), ".exceptd", "attestations");
868
- if (!fs.existsSync(root)) return null;
1566
+ // Search both the v0.11.0 default root (~/.exceptd/) and the legacy cwd-
1567
+ // relative root so operators with prior attestations don't lose their
1568
+ // history when the default moved.
1569
+ const roots = [resolveAttestationRoot(opts), path.join(process.cwd(), ".exceptd", "attestations")];
1570
+ const seen = new Set();
1571
+ const candidates = [];
1572
+ for (const root of roots) {
1573
+ if (seen.has(root) || !fs.existsSync(root)) continue;
1574
+ seen.add(root);
1575
+ walkAttestationDir(root, opts, candidates);
1576
+ }
1577
+ candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
1578
+ return candidates[0] || null;
1579
+ }
1580
+
1581
+ function walkAttestationDir(root, opts, candidates) {
1582
+ if (!fs.existsSync(root)) return;
869
1583
  const sessions = fs.readdirSync(root, { withFileTypes: true })
870
1584
  .filter(d => d.isDirectory())
871
1585
  .map(d => d.name);
872
- const candidates = [];
873
1586
  for (const sid of sessions) {
874
1587
  const sdir = path.join(root, sid);
875
- for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
1588
+ for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json") && !x.endsWith(".sig"))) {
876
1589
  try {
877
1590
  const p = path.join(sdir, f);
878
1591
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
@@ -883,8 +1596,6 @@ function findLatestAttestation(opts = {}) {
883
1596
  } catch { /* skip malformed */ }
884
1597
  }
885
1598
  }
886
- candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
887
- return candidates[0] || null;
888
1599
  }
889
1600
 
890
1601
  function cmdReattest(runner, args, runOpts, pretty) {
@@ -902,10 +1613,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
902
1613
  attFile = found.file;
903
1614
  }
904
1615
  if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
905
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
1616
+ const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
906
1617
  if (!attFile) attFile = path.join(dir, "attestation.json");
907
1618
  if (!fs.existsSync(attFile)) {
908
- return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
1619
+ return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
909
1620
  }
910
1621
  let prior;
911
1622
  try {
@@ -975,33 +1686,640 @@ function cmdReattest(runner, args, runOpts, pretty) {
975
1686
  }, pretty);
976
1687
  }
977
1688
 
978
- function cmdListAttestations(runner, args, runOpts, pretty) {
979
- const root = path.join(process.cwd(), ".exceptd", "attestations");
980
- if (!fs.existsSync(root)) {
981
- return emit({ ok: true, attestations: [], note: `No attestations directory at ${path.relative(process.cwd(), root)}` }, pretty);
1689
+ /**
1690
+ * `exceptd attest <subverb> <session-id>` — auditor-facing operations on
1691
+ * persisted attestations. Subverbs:
1692
+ * export <session-id> Emit redacted JSON suitable for audit submission.
1693
+ * Strips raw artifact values; preserves only
1694
+ * evidence_hash + signatures + classification + RWEP.
1695
+ * Falls back to a CSAF-shaped envelope when --format csaf.
1696
+ * verify <session-id> Verify the .sig sidecar against keys/public.pem.
1697
+ * Reports signed_by + tamper status.
1698
+ * show <session-id> Emit the full (unredacted) attestation. Convenience
1699
+ * alias for `cat .exceptd/attestations/<sid>/attestation.json`.
1700
+ */
1701
+ function cmdAttest(runner, args, runOpts, pretty) {
1702
+ const subverb = args._[0];
1703
+ const sessionId = args._[1];
1704
+ if (!subverb) {
1705
+ return emitError("attest: missing subverb. Usage: attest list | show <sid> | export <sid> | verify <sid> | diff <sid>", null, pretty);
1706
+ }
1707
+ // `list` doesn't require a session-id positional.
1708
+ if (subverb === "list") {
1709
+ return cmdListAttestations(runner, args, runOpts, pretty);
1710
+ }
1711
+ if (!sessionId) {
1712
+ return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
1713
+ }
1714
+ const dir = findSessionDir(sessionId, runOpts);
1715
+ if (!dir) {
1716
+ return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
982
1717
  }
983
- const sessions = fs.readdirSync(root, { withFileTypes: true })
984
- .filter(d => d.isDirectory())
985
- .map(d => d.name);
986
1718
 
987
- const entries = [];
988
- for (const sid of sessions) {
989
- const sdir = path.join(root, sid);
990
- const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json"));
991
- for (const f of files) {
1719
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
1720
+ const attestations = files.map(f => {
1721
+ try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
1722
+ catch { return null; }
1723
+ }).filter(Boolean);
1724
+
1725
+ if (subverb === "show") {
1726
+ emit({ session_id: sessionId, attestations }, pretty);
1727
+ return;
1728
+ }
1729
+
1730
+ if (subverb === "diff") {
1731
+ // `attest diff <session-id> [--against <other-session-id>]` — drift
1732
+ // comparison. Without --against, replays current state against prior
1733
+ // session (= reattest). With --against, compares two sessions A vs B
1734
+ // by evidence_hash + artifact-level field diff.
1735
+ if (args.against) {
1736
+ const otherDir = findSessionDir(args.against, runOpts);
1737
+ if (!otherDir) {
1738
+ return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
1739
+ }
1740
+ const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
1741
+ if (otherFiles.length === 0) {
1742
+ return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
1743
+ }
1744
+ const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
1745
+ const self = attestations[0];
1746
+ emit({
1747
+ verb: "attest diff",
1748
+ a_session: sessionId,
1749
+ b_session: args.against,
1750
+ a_captured: self.captured_at,
1751
+ b_captured: other.captured_at,
1752
+ a_evidence_hash: self.evidence_hash,
1753
+ b_evidence_hash: other.evidence_hash,
1754
+ status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
1755
+ artifact_diff: diffArtifacts((self.submission || {}).artifacts, (other.submission || {}).artifacts),
1756
+ signal_override_diff: diffSignalOverrides((self.submission || {}).signal_overrides, (other.submission || {}).signal_overrides),
1757
+ }, pretty);
1758
+ return;
1759
+ }
1760
+ // Fall through to reattest-style replay below by setting subverb to a
1761
+ // sentinel and re-dispatching via cmdReattest.
1762
+ args._ = [sessionId];
1763
+ return cmdReattest(runner, args, {}, pretty);
1764
+ }
1765
+
1766
+ if (subverb === "list") {
1767
+ return cmdListAttestations(runner, args, {}, pretty);
1768
+ }
1769
+
1770
+ if (subverb === "verify") {
1771
+ const crypto = require("crypto");
1772
+ const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
1773
+ const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
1774
+ const results = files.map(f => {
1775
+ const sigPath = path.join(dir, f + ".sig");
1776
+ if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
1777
+ const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
1778
+ if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
1779
+ if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
1780
+ const content = fs.readFileSync(path.join(dir, f), "utf8");
992
1781
  try {
993
- const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
994
- // Apply --playbook filter if supplied.
995
- if (args.playbook && j.playbook_id !== args.playbook) continue;
996
- entries.push({
997
- session_id: sid,
998
- playbook_id: j.playbook_id,
999
- directive_id: j.directive_id,
1000
- evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
1001
- captured_at: j.captured_at || null,
1002
- file: path.relative(process.cwd(), path.join(sdir, f)),
1003
- });
1004
- } catch { /* skip malformed */ }
1782
+ const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
1783
+ key: pubKey, dsaEncoding: "ieee-p1363",
1784
+ }, Buffer.from(sigDoc.signature_base64, "base64"));
1785
+ return { file: f, signed: true, verified: !!ok, reason: ok ? "Ed25519 signature valid" : "Ed25519 signature INVALID — possible post-hoc tampering" };
1786
+ } catch (e) {
1787
+ return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
1788
+ }
1789
+ });
1790
+ emit({ verb: "attest verify", session_id: sessionId, results }, pretty);
1791
+ return;
1792
+ }
1793
+
1794
+ if (subverb === "export") {
1795
+ // Redaction: strip raw `value` fields from submitted artifacts; preserve
1796
+ // captured-state flag, evidence_hash, classification, RWEP, confidence,
1797
+ // remediation choice, residual risk acceptance, signature. Auditors get
1798
+ // what they need (the verdict + proof of process) without leaking raw
1799
+ // captured data (which may contain PII / secret shapes).
1800
+ const format = args.format || "json";
1801
+ const redacted = attestations.map(a => ({
1802
+ session_id: a.session_id,
1803
+ playbook_id: a.playbook_id,
1804
+ directive_id: a.directive_id,
1805
+ evidence_hash: a.evidence_hash,
1806
+ operator: a.operator,
1807
+ operator_consent: a.operator_consent,
1808
+ captured_at: a.captured_at,
1809
+ run_opts: a.run_opts,
1810
+ artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
1811
+ .map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
1812
+ signal_overrides: (a.submission && a.submission.signal_overrides) || {},
1813
+ signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
1814
+ .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))),
1815
+ precondition_checks: (a.submission && a.submission.precondition_checks) || {},
1816
+ }));
1817
+
1818
+ if (format === "csaf") {
1819
+ // Lightweight CSAF envelope for audit submission — caller can post this
1820
+ // directly to a CSAF-aware GRC platform.
1821
+ emit({
1822
+ document: {
1823
+ category: "csaf_security_advisory",
1824
+ csaf_version: "2.0",
1825
+ publisher: { category: "vendor", name: "exceptd", namespace: "https://exceptd.com" },
1826
+ title: `Auditor export — session ${sessionId}`,
1827
+ tracking: { id: `exceptd-export-${sessionId}`, status: "final", version: "1", initial_release_date: new Date().toISOString() },
1828
+ },
1829
+ exceptd_export: { session_id: sessionId, attestations: redacted, exported_at: new Date().toISOString(), redaction_policy: "v0.10.3-default" },
1830
+ }, pretty);
1831
+ } else {
1832
+ emit({
1833
+ verb: "attest export",
1834
+ session_id: sessionId,
1835
+ exported_at: new Date().toISOString(),
1836
+ redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides + precondition_checks + evidence_hash + signature preserved.",
1837
+ attestations: redacted,
1838
+ }, pretty);
1839
+ }
1840
+ return;
1841
+ }
1842
+
1843
+ return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
1844
+ }
1845
+
1846
+ /**
1847
+ * Per-artifact diff between two submissions. Returns { added, removed, changed }
1848
+ * keyed by artifact id. Used by `attest diff` (bug #34 fix) so operators get
1849
+ * field-level context instead of a binary evidence_hash signal.
1850
+ */
1851
+ function diffArtifacts(a, b) {
1852
+ a = a || {}; b = b || {};
1853
+ const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
1854
+ const out = { added: [], removed: [], changed: [], unchanged_count: 0 };
1855
+ for (const id of allIds) {
1856
+ const av = a[id], bv = b[id];
1857
+ if (!av && bv) out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
1858
+ else if (av && !bv) out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
1859
+ else if (JSON.stringify(av) !== JSON.stringify(bv)) {
1860
+ out.changed.push({
1861
+ id,
1862
+ a_captured: !!av.captured, b_captured: !!bv.captured,
1863
+ a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
1864
+ });
1865
+ } else { out.unchanged_count++; }
1866
+ }
1867
+ return out;
1868
+ }
1869
+
1870
+ function diffSignalOverrides(a, b) {
1871
+ a = a || {}; b = b || {};
1872
+ const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
1873
+ const out = { changed: [], unchanged_count: 0 };
1874
+ for (const id of allIds) {
1875
+ if (a[id] !== b[id]) out.changed.push({ id, a: a[id] || null, b: b[id] || null });
1876
+ else out.unchanged_count++;
1877
+ }
1878
+ return out;
1879
+ }
1880
+
1881
+ function previewValue(v) {
1882
+ if (v === null || v === undefined) return null;
1883
+ const s = typeof v === "string" ? v : JSON.stringify(v);
1884
+ return s.length > 80 ? s.slice(0, 80) + "…" : s;
1885
+ }
1886
+
1887
+ // ---------------------------------------------------------------------------
1888
+ // v0.11.0: cmdDiscover — context-aware playbook recommender.
1889
+ // Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
1890
+ // /etc/os-release on Linux, and outputs a list of recommended playbooks.
1891
+ // ---------------------------------------------------------------------------
1892
+ function cmdDiscover(runner, args, runOpts, pretty) {
1893
+ const cwd = process.cwd();
1894
+ const wantJson = !!args.json || !!args.pretty;
1895
+ const indent = !!args.pretty;
1896
+
1897
+ // File-presence sniffer. Each probe is independently fault-tolerant so a
1898
+ // permission error on one path can't poison the whole detection.
1899
+ const detected = [];
1900
+ function probe(rel, label) {
1901
+ try {
1902
+ if (fs.existsSync(path.join(cwd, rel))) detected.push(label || rel);
1903
+ } catch { /* swallow */ }
1904
+ }
1905
+ probe(".git", ".git/");
1906
+ probe("package.json");
1907
+ probe("package-lock.json");
1908
+ probe("yarn.lock");
1909
+ probe("pnpm-lock.yaml");
1910
+ probe("pyproject.toml");
1911
+ probe("requirements.txt");
1912
+ probe("Pipfile");
1913
+ probe("Cargo.toml");
1914
+ probe("go.mod");
1915
+ probe("Dockerfile");
1916
+ probe("docker-compose.yml");
1917
+ probe("docker-compose.yaml");
1918
+ probe("kustomization.yaml");
1919
+ probe("k8s", "k8s/");
1920
+ probe(".env");
1921
+ probe(".envrc");
1922
+
1923
+ // Terraform / IaC — glob the top level for *.tf.
1924
+ try {
1925
+ const tfFiles = fs.readdirSync(cwd).filter(f => f.endsWith(".tf"));
1926
+ if (tfFiles.length) detected.push(`*.tf (${tfFiles.length})`);
1927
+ } catch { /* swallow */ }
1928
+
1929
+ // Git remote (best-effort, never fatal).
1930
+ let gitRemote = null;
1931
+ if (detected.includes(".git/")) {
1932
+ try {
1933
+ const headPath = path.join(cwd, ".git", "config");
1934
+ if (fs.existsSync(headPath)) {
1935
+ const cfg = fs.readFileSync(headPath, "utf8");
1936
+ const m = cfg.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(\S+)/);
1937
+ if (m) gitRemote = m[1];
1938
+ }
1939
+ } catch { /* swallow */ }
1940
+ }
1941
+
1942
+ // Host platform / distro.
1943
+ const hostPlatform = process.platform;
1944
+ let hostDistro = null;
1945
+ if (hostPlatform === "linux") {
1946
+ try {
1947
+ const res = spawnSync("cat", ["/etc/os-release"], { encoding: "utf8" });
1948
+ if (res.status === 0 && res.stdout) {
1949
+ const idMatch = res.stdout.match(/^ID=(.+)$/m);
1950
+ const verMatch = res.stdout.match(/^VERSION_ID=(.+)$/m);
1951
+ const prettyMatch = res.stdout.match(/^PRETTY_NAME=(.+)$/m);
1952
+ hostDistro = {
1953
+ id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
1954
+ version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
1955
+ pretty_name: prettyMatch ? prettyMatch[1].replace(/^"|"$/g, "") : null,
1956
+ };
1957
+ }
1958
+ } catch { /* swallow */ }
1959
+ }
1960
+
1961
+ // Build recommendation set. Dedup by playbook id so multi-trigger rules
1962
+ // don't double-list.
1963
+ const isRepo = detected.includes(".git/");
1964
+ const hasNode = detected.includes("package.json") || detected.includes("package-lock.json")
1965
+ || detected.includes("yarn.lock") || detected.includes("pnpm-lock.yaml");
1966
+ const hasPython = detected.includes("pyproject.toml") || detected.includes("requirements.txt")
1967
+ || detected.includes("Pipfile");
1968
+ const hasRust = detected.includes("Cargo.toml");
1969
+ const hasGo = detected.includes("go.mod");
1970
+ const hasLockfile = hasNode || hasPython || hasRust || hasGo;
1971
+ const hasContainers = detected.includes("Dockerfile") || detected.includes("docker-compose.yml")
1972
+ || detected.includes("docker-compose.yaml");
1973
+ const isLinux = hostPlatform === "linux";
1974
+
1975
+ const recs = [];
1976
+ const seen = new Set();
1977
+ function recommend(id, reason) {
1978
+ if (seen.has(id)) return;
1979
+ seen.add(id);
1980
+ recs.push({ id, reason });
1981
+ }
1982
+
1983
+ if (isRepo && hasLockfile) {
1984
+ const langs = [hasNode && "node", hasPython && "python", hasRust && "rust", hasGo && "go"]
1985
+ .filter(Boolean).join("/");
1986
+ recommend("secrets", `git repo + ${langs} lockfile → check for committed credentials`);
1987
+ recommend("sbom", `git repo + ${langs} lockfile → SBOM + supply-chain integrity`);
1988
+ recommend("library-author", `git repo + ${langs} lockfile → publisher-side audit`);
1989
+ recommend("crypto-codebase", `git repo + ${langs} lockfile → cryptographic primitive review`);
1990
+ }
1991
+ if (hasContainers) {
1992
+ recommend("containers", "Dockerfile / docker-compose present → container security review");
1993
+ }
1994
+ if (isLinux) {
1995
+ recommend("kernel", "Linux host detected → kernel LPE / privilege escalation triage");
1996
+ recommend("hardening", "Linux host detected → system hardening review");
1997
+ recommend("runtime", "Linux host detected → runtime behavior review");
1998
+ recommend("cred-stores", "Linux host detected → credential store review");
1999
+ }
2000
+ // Always include cross-cutting framework correlation.
2001
+ recommend("framework", "cross-cutting: framework correlation always applicable");
2002
+
2003
+ const nextSteps = [
2004
+ "exceptd brief <playbook> # learn what a playbook checks",
2005
+ "exceptd run <playbook> # run it",
2006
+ "exceptd run --scope code # run all code-scoped playbooks (auto-detected)",
2007
+ "exceptd ci --scope code # CI-gate against all code-scoped playbooks",
2008
+ ];
2009
+
2010
+ const out = {
2011
+ verb: "discover",
2012
+ context: {
2013
+ cwd,
2014
+ git_remote: gitRemote,
2015
+ detected_files: detected,
2016
+ host_platform: hostPlatform,
2017
+ host_distro: hostDistro,
2018
+ },
2019
+ recommended_playbooks: recs,
2020
+ next_steps: nextSteps,
2021
+ };
2022
+
2023
+ // --scan-only: also run legacy `scan` and embed under legacy_scan. Use
2024
+ // spawnSync against orchestrator/index.js — the orchestrator was designed
2025
+ // to be invoked as a subprocess, and isolating it via spawn prevents one
2026
+ // bad scanner from killing the whole discover verb.
2027
+ if (args["scan-only"]) {
2028
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2029
+ try {
2030
+ const res = spawnSync(process.execPath, [orchPath, "scan", "--json"], {
2031
+ encoding: "utf8",
2032
+ cwd,
2033
+ timeout: 30000,
2034
+ });
2035
+ if (res.status === 0 && res.stdout) {
2036
+ try { out.legacy_scan = JSON.parse(res.stdout); }
2037
+ catch { out.legacy_scan = { ok: false, raw: res.stdout.slice(0, 2000), parse_error: true }; }
2038
+ } else {
2039
+ out.legacy_scan = {
2040
+ ok: false,
2041
+ exit_code: res.status,
2042
+ stderr: (res.stderr || "").slice(0, 2000),
2043
+ };
2044
+ }
2045
+ } catch (e) {
2046
+ out.legacy_scan = { ok: false, error: e.message };
2047
+ }
2048
+ }
2049
+
2050
+ if (wantJson) {
2051
+ emit(out, indent);
2052
+ return;
2053
+ }
2054
+
2055
+ // Default: human-readable text. (v0.11.0 redesign #5 — flipped defaults.)
2056
+ const lines = [];
2057
+ lines.push("exceptd discover");
2058
+ lines.push(` cwd: ${cwd}`);
2059
+ if (gitRemote) lines.push(` git remote: ${gitRemote}`);
2060
+ lines.push(` platform: ${hostPlatform}${hostDistro && hostDistro.pretty_name ? " (" + hostDistro.pretty_name + ")" : ""}`);
2061
+ lines.push(` detected: ${detected.length ? detected.join(", ") : "(nothing recognized)"}`);
2062
+ lines.push("");
2063
+ lines.push(`Recommended playbooks (${recs.length}):`);
2064
+ for (const r of recs) {
2065
+ lines.push(` - ${r.id.padEnd(20)} ${r.reason}`);
2066
+ }
2067
+ lines.push("");
2068
+ lines.push("Next steps:");
2069
+ for (const s of nextSteps) lines.push(` ${s}`);
2070
+ if (out.legacy_scan) {
2071
+ lines.push("");
2072
+ lines.push(`legacy scan: ${out.legacy_scan.ok === false ? "FAILED" : "ok"}`);
2073
+ }
2074
+ process.stdout.write(lines.join("\n") + "\n");
2075
+ }
2076
+
2077
+ // ---------------------------------------------------------------------------
2078
+ // v0.11.0: cmdDoctor — one-shot health check.
2079
+ // Collapses verify + currency + validate-cves + validate-rfcs + signing-status.
2080
+ // Each subcheck is independently fault-tolerant: a single failure surfaces
2081
+ // in the JSON but never crashes the verb.
2082
+ // ---------------------------------------------------------------------------
2083
+ function cmdDoctor(runner, args, runOpts, pretty) {
2084
+ const wantJson = !!args.json || !!args.pretty;
2085
+ const indent = !!args.pretty;
2086
+
2087
+ // Selective subchecks. If any of the four flags is passed, run only those.
2088
+ // If none are passed, run all four plus signing-status.
2089
+ const onlySigs = !!args.signatures;
2090
+ const onlyCurrency = !!args.currency;
2091
+ const onlyCves = !!args.cves;
2092
+ const onlyRfcs = !!args.rfcs;
2093
+ const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs;
2094
+ const runSigs = !anySelected || onlySigs;
2095
+ const runCurrency = !anySelected || onlyCurrency;
2096
+ const runCves = !anySelected || onlyCves;
2097
+ const runRfcs = !anySelected || onlyRfcs;
2098
+ const runSigning = !anySelected;
2099
+
2100
+ const checks = {};
2101
+ const issues = [];
2102
+
2103
+ if (runSigs) {
2104
+ try {
2105
+ const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
2106
+ const res = spawnSync(process.execPath, [verifyPath], {
2107
+ encoding: "utf8",
2108
+ cwd: PKG_ROOT,
2109
+ timeout: 30000,
2110
+ });
2111
+ const text = (res.stdout || "") + (res.stderr || "");
2112
+ const okMatch = text.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
2113
+ const fpMatch = text.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
2114
+ const ok = res.status === 0;
2115
+ checks.signatures = {
2116
+ ok,
2117
+ skills_passed: okMatch ? Number(okMatch[1]) : null,
2118
+ skills_total: okMatch ? Number(okMatch[2]) : null,
2119
+ fingerprint_sha256: fpMatch ? fpMatch[1] : null,
2120
+ ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2121
+ };
2122
+ if (!ok) issues.push("signatures");
2123
+ } catch (e) {
2124
+ checks.signatures = { ok: false, error: e.message };
2125
+ issues.push("signatures");
2126
+ }
2127
+ }
2128
+
2129
+ if (runCurrency) {
2130
+ try {
2131
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2132
+ const res = spawnSync(process.execPath, [orchPath, "currency", "--json"], {
2133
+ encoding: "utf8",
2134
+ cwd: PKG_ROOT,
2135
+ timeout: 30000,
2136
+ });
2137
+ let parsed = null;
2138
+ if (res.stdout) {
2139
+ const m = res.stdout.match(/\{[\s\S]*\}\s*$/);
2140
+ if (m) {
2141
+ try { parsed = JSON.parse(m[0]); } catch { /* fall through */ }
2142
+ }
2143
+ }
2144
+ if (parsed && Array.isArray(parsed.currency_report)) {
2145
+ const stale = parsed.currency_report.filter(s => s.action_required || s.currency_label !== "current");
2146
+ const critical = parsed.currency_report.filter(s => s.currency_score !== undefined && s.currency_score < 50);
2147
+ const ok = stale.length === 0 && !parsed.action_required;
2148
+ checks.currency = {
2149
+ ok,
2150
+ total_skills: parsed.currency_report.length,
2151
+ stale_skills: stale.map(s => s.skill),
2152
+ critical_stale: critical.map(s => s.skill),
2153
+ critical_count: parsed.critical_count || 0,
2154
+ };
2155
+ if (!ok) issues.push("currency");
2156
+ } else {
2157
+ checks.currency = {
2158
+ ok: res.status === 0,
2159
+ exit_code: res.status,
2160
+ raw: (res.stdout || res.stderr || "").slice(0, 500),
2161
+ parse_error: true,
2162
+ };
2163
+ if (res.status !== 0) issues.push("currency");
2164
+ }
2165
+ } catch (e) {
2166
+ checks.currency = { ok: false, error: e.message };
2167
+ issues.push("currency");
2168
+ }
2169
+ }
2170
+
2171
+ if (runCves) {
2172
+ try {
2173
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2174
+ // validate-cves doesn't emit JSON; parse text for row count + drift.
2175
+ const res = spawnSync(process.execPath, [orchPath, "validate-cves", "--offline"], {
2176
+ encoding: "utf8",
2177
+ cwd: PKG_ROOT,
2178
+ timeout: 30000,
2179
+ });
2180
+ const text = (res.stdout || "") + (res.stderr || "");
2181
+ const totalMatch = text.match(/(\d+)\s+CVEs?\s+in\s+catalog/i);
2182
+ const driftMatch = text.match(/drift[:\s]+(\d+)/i);
2183
+ const ok = res.status === 0;
2184
+ checks.cves = {
2185
+ ok,
2186
+ total: totalMatch ? Number(totalMatch[1]) : null,
2187
+ drift: driftMatch ? Number(driftMatch[1]) : 0,
2188
+ ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2189
+ };
2190
+ if (!ok) issues.push("cves");
2191
+ } catch (e) {
2192
+ checks.cves = { ok: false, error: e.message };
2193
+ issues.push("cves");
2194
+ }
2195
+ }
2196
+
2197
+ if (runRfcs) {
2198
+ try {
2199
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2200
+ const res = spawnSync(process.execPath, [orchPath, "validate-rfcs", "--offline"], {
2201
+ encoding: "utf8",
2202
+ cwd: PKG_ROOT,
2203
+ timeout: 30000,
2204
+ });
2205
+ const text = (res.stdout || "") + (res.stderr || "");
2206
+ const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
2207
+ const driftMatch = text.match(/drift[:\s]+(\d+)/i);
2208
+ const ok = res.status === 0;
2209
+ checks.rfcs = {
2210
+ ok,
2211
+ total: rfcRows,
2212
+ drift: driftMatch ? Number(driftMatch[1]) : 0,
2213
+ ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2214
+ };
2215
+ if (!ok) issues.push("rfcs");
2216
+ } catch (e) {
2217
+ checks.rfcs = { ok: false, error: e.message };
2218
+ issues.push("rfcs");
2219
+ }
2220
+ }
2221
+
2222
+ if (runSigning) {
2223
+ try {
2224
+ const keyPath = path.join(process.cwd(), ".keys", "private.pem");
2225
+ const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
2226
+ const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
2227
+ checks.signing = {
2228
+ ok: true, // signing-status is informational, never "fails"
2229
+ private_key_present: present,
2230
+ can_sign_attestations: present,
2231
+ ...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` to enable attestation signing" }),
2232
+ };
2233
+ } catch (e) {
2234
+ checks.signing = { ok: false, error: e.message };
2235
+ }
2236
+ }
2237
+
2238
+ const allGreen = issues.length === 0;
2239
+ const out = {
2240
+ verb: "doctor",
2241
+ checks,
2242
+ summary: { all_green: allGreen, issues_count: issues.length, failed_checks: issues },
2243
+ };
2244
+
2245
+ if (wantJson) {
2246
+ emit(out, indent);
2247
+ if (!allGreen) process.exitCode = 1;
2248
+ return;
2249
+ }
2250
+
2251
+ // Default: human checklist. v0.11.0 redesign #5.
2252
+ const lines = [];
2253
+ lines.push("exceptd doctor");
2254
+ function mark(c, render) {
2255
+ if (!c) return;
2256
+ const icon = c.ok ? "[ok]" : "[!!]";
2257
+ lines.push(` ${icon} ${render(c)}`);
2258
+ }
2259
+ mark(checks.signatures, c =>
2260
+ c.ok
2261
+ ? `skill signatures verified (${c.skills_passed ?? "?"}/${c.skills_total ?? "?"})`
2262
+ : `skill signatures FAILED (exit=${c.exit_code ?? "?"})`
2263
+ );
2264
+ mark(checks.currency, c =>
2265
+ c.ok
2266
+ ? `skill currency: all green (${c.total_skills ?? "?"} skills)`
2267
+ : `skill currency: ${c.stale_skills?.length || "?"} stale, ${c.critical_count ?? 0} critical`
2268
+ );
2269
+ mark(checks.cves, c =>
2270
+ c.ok
2271
+ ? `CVE catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
2272
+ : `CVE catalog FAILED (exit=${c.exit_code ?? "?"})`
2273
+ );
2274
+ mark(checks.rfcs, c =>
2275
+ c.ok
2276
+ ? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
2277
+ : `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
2278
+ );
2279
+ if (checks.signing) {
2280
+ if (checks.signing.private_key_present) {
2281
+ lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
2282
+ } else {
2283
+ lines.push(` [!!] attestation signing: private key MISSING (.keys/private.pem) — run \`node lib/sign.js generate-keypair\` to enable`);
2284
+ }
2285
+ }
2286
+ lines.push("");
2287
+ lines.push(allGreen ? `summary: all checks green` : `summary: ${issues.length} issue(s) — ${issues.join(", ")}`);
2288
+ process.stdout.write(lines.join("\n") + "\n");
2289
+ if (!allGreen) process.exitCode = 1;
2290
+ }
2291
+
2292
+ function cmdListAttestations(runner, args, runOpts, pretty) {
2293
+ // Enumerate sessions across both v0.11.0 default root and legacy cwd-
2294
+ // relative root, so operators with prior attestations still see them.
2295
+ const roots = [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")];
2296
+ const entries = [];
2297
+ const seenRoots = new Set();
2298
+ for (const root of roots) {
2299
+ if (seenRoots.has(root) || !fs.existsSync(root)) continue;
2300
+ seenRoots.add(root);
2301
+ const sessions = fs.readdirSync(root, { withFileTypes: true })
2302
+ .filter(d => d.isDirectory())
2303
+ .map(d => d.name);
2304
+ for (const sid of sessions) {
2305
+ const sdir = path.join(root, sid);
2306
+ const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
2307
+ for (const f of files) {
2308
+ try {
2309
+ const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
2310
+ if (args.playbook && j.playbook_id !== args.playbook) continue;
2311
+ if (args.since && (j.captured_at || "") < args.since) continue;
2312
+ entries.push({
2313
+ session_id: sid,
2314
+ playbook_id: j.playbook_id,
2315
+ directive_id: j.directive_id,
2316
+ evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
2317
+ captured_at: j.captured_at || null,
2318
+ attestation_root: root,
2319
+ file: path.join(sdir, f),
2320
+ });
2321
+ } catch { /* skip malformed */ }
2322
+ }
1005
2323
  }
1006
2324
  }
1007
2325
  entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
@@ -1009,10 +2327,423 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
1009
2327
  ok: true,
1010
2328
  attestations: entries,
1011
2329
  count: entries.length,
1012
- filter: { playbook: args.playbook || null },
2330
+ filter: { playbook: args.playbook || null, since: args.since || null },
2331
+ roots_searched: [...seenRoots],
1013
2332
  }, pretty);
1014
2333
  }
1015
2334
 
2335
+ // ---------------------------------------------------------------------------
2336
+ // v0.11.0 verbs: ai-run, ask, ci
2337
+ // ---------------------------------------------------------------------------
2338
+
2339
+ /**
2340
+ * `ai-run <playbook>` — streaming JSONL contract for AI-driven runs.
2341
+ *
2342
+ * Emits one JSON object per line over stdout as the seven phases progress;
2343
+ * reads {"event":"evidence","payload":{observations,verdict}} from stdin
2344
+ * once it's announced the await_evidence phase. Designed so a host AI can
2345
+ * pipe one bidirectional channel instead of doing brief → look → run as
2346
+ * three CLI round-trips with an intermediate evidence file.
2347
+ *
2348
+ * --no-stream falls back to a single JSON document combining every phase
2349
+ * for callers that don't want event-driven I/O (smoke tests, batch jobs).
2350
+ */
2351
+ function cmdAiRun(runner, args, runOpts, pretty) {
2352
+ const playbookId = args._[0];
2353
+ if (!playbookId) {
2354
+ return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
2355
+ }
2356
+ let pb;
2357
+ try { pb = runner.loadPlaybook(playbookId); }
2358
+ catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
2359
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2360
+ if (!directiveId) {
2361
+ return emitError(`ai-run: playbook ${playbookId} has no directives.`, null, pretty);
2362
+ }
2363
+
2364
+ // Compute the informational phases up front — both stream and no-stream
2365
+ // modes share them.
2366
+ let governPhase, directPhase, lookPhase;
2367
+ try {
2368
+ governPhase = runner.govern(playbookId, directiveId, runOpts);
2369
+ directPhase = runner.direct(playbookId, directiveId);
2370
+ lookPhase = runner.look(playbookId, directiveId, runOpts);
2371
+ } catch (e) {
2372
+ process.stdout.write(JSON.stringify({ event: "error", reason: e.message, phase: "info" }) + "\n");
2373
+ process.exit(1);
2374
+ }
2375
+
2376
+ const governEvent = {
2377
+ phase: "govern",
2378
+ playbook_id: playbookId,
2379
+ directive_id: directiveId,
2380
+ jurisdiction_obligations: governPhase.jurisdiction_obligations || [],
2381
+ theater_fingerprints: governPhase.theater_fingerprints || [],
2382
+ framework_context: governPhase.framework_context || null,
2383
+ skill_preload: governPhase.skill_preload || [],
2384
+ };
2385
+ const directEvent = {
2386
+ phase: "direct",
2387
+ threat_context: directPhase.threat_context || null,
2388
+ rwep_threshold: directPhase.rwep_threshold || null,
2389
+ framework_lag_declaration: directPhase.framework_lag_declaration || null,
2390
+ skill_chain: directPhase.skill_chain || [],
2391
+ token_budget: directPhase.token_budget || null,
2392
+ };
2393
+ const lookEvent = {
2394
+ phase: "look",
2395
+ artifacts_required: (lookPhase.artifacts || []).filter(a => a.required),
2396
+ artifacts_optional: (lookPhase.artifacts || []).filter(a => !a.required),
2397
+ preconditions: lookPhase.preconditions || [],
2398
+ precondition_submission_shape: lookPhase.precondition_submission_shape || null,
2399
+ collection_scope: lookPhase.collection_scope || null,
2400
+ };
2401
+ const submissionShape = {
2402
+ observations: {},
2403
+ verdict: {},
2404
+ note: "Send back as {\"event\":\"evidence\",\"payload\":{\"observations\":{...},\"verdict\":{...}}}.",
2405
+ };
2406
+
2407
+ // ----- single-shot path -----
2408
+ if (args["no-stream"]) {
2409
+ // Read any pre-supplied evidence from stdin OR from --evidence flag.
2410
+ let payload = { observations: {}, verdict: {} };
2411
+ if (args.evidence) {
2412
+ try { payload = readEvidence(args.evidence); }
2413
+ catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
2414
+ } else if (!process.stdin.isTTY) {
2415
+ // Drain stdin for any evidence event.
2416
+ try {
2417
+ const buf = fs.readFileSync(0, "utf8");
2418
+ if (buf.trim()) {
2419
+ // Accept either a bare submission object or a single evidence event.
2420
+ for (const line of buf.split(/\r?\n/)) {
2421
+ const t = line.trim();
2422
+ if (!t) continue;
2423
+ try {
2424
+ const parsed = JSON.parse(t);
2425
+ if (parsed && parsed.event === "evidence" && parsed.payload) {
2426
+ payload = parsed.payload;
2427
+ break;
2428
+ }
2429
+ // Bare submission fallback.
2430
+ if (parsed && (parsed.observations || parsed.artifacts || parsed.signal_overrides)) {
2431
+ payload = parsed.observations
2432
+ ? parsed
2433
+ : { observations: { ...(parsed.artifacts || {}), ...(parsed.signal_overrides || {}) }, verdict: parsed.signals || {} };
2434
+ break;
2435
+ }
2436
+ } catch { /* skip non-JSON lines */ }
2437
+ }
2438
+ }
2439
+ } catch { /* stdin empty / unreadable — fall through with empty payload */ }
2440
+ }
2441
+ const submission = buildSubmissionFromPayload(payload);
2442
+ let result;
2443
+ try {
2444
+ result = runner.run(playbookId, directiveId, submission, runOpts);
2445
+ } catch (e) {
2446
+ return emitError(`ai-run: runner threw: ${e.message}`, { playbook: playbookId }, pretty);
2447
+ }
2448
+ if (!result || result.ok === false) {
2449
+ process.stderr.write((pretty ? JSON.stringify(result || {}, null, 2) : JSON.stringify(result || {})) + "\n");
2450
+ process.exit(1);
2451
+ }
2452
+ emit({
2453
+ verb: "ai-run",
2454
+ mode: "no-stream",
2455
+ playbook_id: playbookId,
2456
+ directive_id: directiveId,
2457
+ govern: governEvent,
2458
+ direct: directEvent,
2459
+ look: lookEvent,
2460
+ detect: result.phases?.detect || null,
2461
+ analyze: result.phases?.analyze || null,
2462
+ validate: result.phases?.validate || null,
2463
+ close: result.phases?.close || null,
2464
+ session_id: result.session_id,
2465
+ evidence_hash: result.evidence_hash,
2466
+ }, pretty);
2467
+ return;
2468
+ }
2469
+
2470
+ // ----- streaming path -----
2471
+ // Emit info phases immediately, then wait for an evidence event on stdin.
2472
+ const writeLine = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
2473
+ writeLine(governEvent);
2474
+ writeLine(directEvent);
2475
+ writeLine(lookEvent);
2476
+ writeLine({ phase: "await_evidence", submission_shape: submissionShape });
2477
+
2478
+ let handled = false;
2479
+ let buf = "";
2480
+
2481
+ const handleLine = (line) => {
2482
+ if (handled) return;
2483
+ let parsed;
2484
+ try { parsed = JSON.parse(line); }
2485
+ catch (e) {
2486
+ writeLine({ event: "error", reason: `invalid JSON on stdin: ${e.message}`, line_preview: line.slice(0, 120) });
2487
+ process.exit(1);
2488
+ }
2489
+ if (!parsed || parsed.event !== "evidence" || !parsed.payload) {
2490
+ // Ignore non-evidence chatter so the host AI can interleave its own
2491
+ // status events; only an "evidence" event triggers phases 4-7.
2492
+ return;
2493
+ }
2494
+ handled = true;
2495
+ const submission = buildSubmissionFromPayload(parsed.payload);
2496
+ let result;
2497
+ try {
2498
+ result = runner.run(playbookId, directiveId, submission, runOpts);
2499
+ } catch (e) {
2500
+ writeLine({ event: "error", reason: `runner threw: ${e.message}` });
2501
+ process.exit(1);
2502
+ }
2503
+ if (!result || result.ok === false) {
2504
+ writeLine({ event: "error", reason: result?.reason || "runner returned ok:false", result });
2505
+ process.exit(1);
2506
+ }
2507
+ writeLine({ phase: "detect", ...result.phases?.detect });
2508
+ writeLine({ phase: "analyze", ...result.phases?.analyze });
2509
+ writeLine({ phase: "validate", ...result.phases?.validate });
2510
+ writeLine({ phase: "close", ...result.phases?.close });
2511
+ writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
2512
+ process.exit(0);
2513
+ };
2514
+
2515
+ // Handle empty/closed stdin: emit a hint then exit cleanly so AI agents
2516
+ // calling ai-run without piping anything see a useful message rather than
2517
+ // a hung process.
2518
+ if (process.stdin.isTTY) {
2519
+ writeLine({ event: "error", reason: "ai-run streaming mode requires evidence on stdin; pipe {\"event\":\"evidence\",\"payload\":{...}} or use --no-stream." });
2520
+ process.exit(1);
2521
+ }
2522
+
2523
+ process.stdin.on("data", (chunk) => {
2524
+ buf += chunk.toString();
2525
+ let nl;
2526
+ while ((nl = buf.indexOf("\n")) !== -1) {
2527
+ const line = buf.slice(0, nl).trim();
2528
+ buf = buf.slice(nl + 1);
2529
+ if (line) handleLine(line);
2530
+ }
2531
+ });
2532
+ process.stdin.on("end", () => {
2533
+ // Final flush — handle a trailing line without a newline.
2534
+ const tail = buf.trim();
2535
+ if (tail) handleLine(tail);
2536
+ if (!handled) {
2537
+ writeLine({ event: "error", reason: "stdin closed without an evidence event." });
2538
+ process.exit(1);
2539
+ }
2540
+ });
2541
+ }
2542
+
2543
+ /**
2544
+ * Coerce a stdin payload into the runner submission shape. Accepts both the
2545
+ * v0.11.0 ai-run shape (observations + verdict) and the nested v0.10.x shape
2546
+ * (artifacts + signal_overrides + signals) for forward/back compat.
2547
+ */
2548
+ function buildSubmissionFromPayload(payload) {
2549
+ if (!payload || typeof payload !== "object") return { artifacts: {}, signal_overrides: {}, signals: {} };
2550
+ // Nested v0.10.x shape passthrough.
2551
+ if (payload.artifacts || payload.signal_overrides || payload.signals) {
2552
+ return {
2553
+ artifacts: payload.artifacts || {},
2554
+ signal_overrides: payload.signal_overrides || {},
2555
+ signals: payload.signals || {},
2556
+ precondition_checks: payload.precondition_checks || undefined,
2557
+ };
2558
+ }
2559
+ // v0.11.0 flat shape: observations becomes the artifacts+signal_overrides
2560
+ // union (the runner normalises both via normalizeSubmission), verdict
2561
+ // becomes signals.
2562
+ return {
2563
+ artifacts: payload.observations || {},
2564
+ signal_overrides: payload.observations || {},
2565
+ signals: payload.verdict || {},
2566
+ precondition_checks: payload.precondition_checks || undefined,
2567
+ };
2568
+ }
2569
+
2570
+ /**
2571
+ * `ask "<question>"` — plain-English routing. Scores every playbook by token
2572
+ * overlap against domain.name + domain.attack_class + first sentence of
2573
+ * phases.direct.threat_context. Returns the top 5 matches with a confidence
2574
+ * score (matched tokens / total tokens).
2575
+ */
2576
+ function cmdAsk(runner, args, runOpts, pretty) {
2577
+ const question = (args._ || []).join(" ").trim();
2578
+ if (!question) {
2579
+ return emitError("ask: usage: exceptd ask \"<plain-English question>\"", null, pretty);
2580
+ }
2581
+ const ids = runner.listPlaybooks();
2582
+ const q = question.toLowerCase();
2583
+ const tokens = q.split(/\W+/).filter(t => t.length > 3);
2584
+ const scored = [];
2585
+ for (const id of ids) {
2586
+ let pb;
2587
+ try { pb = runner.loadPlaybook(id); } catch { continue; }
2588
+ const threat = pb.phases?.direct?.threat_context || "";
2589
+ const firstSentence = threat.split(/(?<=[.!?])\s+/)[0] || "";
2590
+ const haystack = [
2591
+ pb.domain?.name || "",
2592
+ pb.domain?.attack_class || "",
2593
+ firstSentence,
2594
+ ].join(" ").toLowerCase();
2595
+ const score = tokens.filter(t => haystack.includes(t)).length;
2596
+ scored.push({ id: pb._meta?.id || id, score });
2597
+ }
2598
+ scored.sort((a, b) => b.score - a.score);
2599
+ const top = scored.filter(s => s.score > 0).slice(0, 5);
2600
+
2601
+ if (top.length === 0) {
2602
+ emit({
2603
+ verb: "ask",
2604
+ question,
2605
+ matched: [],
2606
+ hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
2607
+ }, pretty);
2608
+ return;
2609
+ }
2610
+
2611
+ emit({
2612
+ verb: "ask",
2613
+ question,
2614
+ routed_to: top.map(t => t.id),
2615
+ confidence: top[0].score / Math.max(1, tokens.length),
2616
+ next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
2617
+ full_match_list: top,
2618
+ }, pretty);
2619
+ }
2620
+
2621
+ /**
2622
+ * `ci [--all|--scope <type>]` — top-level CI gate. Effectively
2623
+ * `run --all --ci` packaged as a verb so .github/workflows lines are short.
2624
+ *
2625
+ * Exit codes:
2626
+ * 0 PASS — no detected findings, no rwep ≥ cap, no clock started (when
2627
+ * --block-on-jurisdiction-clock is set).
2628
+ * 2 FAIL — any of the above tripped.
2629
+ */
2630
+ function cmdCi(runner, args, runOpts, pretty) {
2631
+ const scope = args.scope;
2632
+ const maxRwep = args["max-rwep"] !== undefined ? Number(args["max-rwep"]) : null;
2633
+ const blockOnClock = !!args["block-on-jurisdiction-clock"];
2634
+
2635
+ let ids;
2636
+ if (args.all) {
2637
+ ids = runner.listPlaybooks();
2638
+ } else if (scope) {
2639
+ ids = filterPlaybooksByScope(runner, scope);
2640
+ } else {
2641
+ const scopes = detectScopes();
2642
+ ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
2643
+ ids = [...new Set(ids)];
2644
+ }
2645
+ if (!ids || ids.length === 0) {
2646
+ return emitError("ci: no playbooks matched. Pass --all, --scope <type>, or run from a repo/Linux-host context.", null, pretty);
2647
+ }
2648
+
2649
+ const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
2650
+
2651
+ // Evidence: --evidence <file> or --evidence-dir <dir>. Both produce a
2652
+ // bundle keyed by playbook id; ids without a key get an empty submission.
2653
+ let bundle = {};
2654
+ if (args.evidence) {
2655
+ try { bundle = readEvidence(args.evidence); }
2656
+ catch (e) { return emitError(`ci: failed to read --evidence: ${e.message}`, null, pretty); }
2657
+ }
2658
+ if (args["evidence-dir"]) {
2659
+ const dir = args["evidence-dir"];
2660
+ if (!fs.existsSync(dir)) {
2661
+ return emitError(`ci: --evidence-dir ${dir} does not exist.`, null, pretty);
2662
+ }
2663
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
2664
+ try {
2665
+ bundle[f.replace(/\.json$/, "")] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
2666
+ } catch (e) {
2667
+ return emitError(`ci: failed to parse evidence-dir entry ${f}: ${e.message}`, null, pretty);
2668
+ }
2669
+ }
2670
+ }
2671
+
2672
+ const results = [];
2673
+ let fail = false;
2674
+ let failReasons = [];
2675
+
2676
+ for (const id of ids) {
2677
+ let pb;
2678
+ try { pb = runner.loadPlaybook(id); }
2679
+ catch (e) { results.push({ playbook_id: id, ok: false, error: e.message }); fail = true; continue; }
2680
+ const directiveId = (pb.directives[0] && pb.directives[0].id);
2681
+ if (!directiveId) {
2682
+ results.push({ playbook_id: id, ok: false, error: "no directives" });
2683
+ fail = true;
2684
+ continue;
2685
+ }
2686
+ const submission = bundle[id] || {};
2687
+ const perOpts = { ...runOpts, session_id: sessionId };
2688
+ if (submission.precondition_checks) perOpts.precondition_checks = submission.precondition_checks;
2689
+ let result;
2690
+ try { result = runner.run(id, directiveId, submission, perOpts); }
2691
+ catch (e) { result = { ok: false, error: e.message, playbook_id: id }; }
2692
+ results.push(result);
2693
+ if (!result || result.ok === false) {
2694
+ fail = true;
2695
+ failReasons.push(`${id}: blocked (${result?.reason || result?.error || "unknown"})`);
2696
+ continue;
2697
+ }
2698
+ const cls = result.phases?.detect?.classification;
2699
+ const rwepAdj = result.phases?.analyze?.rwep?.adjusted ?? 0;
2700
+ const cap = maxRwep !== null
2701
+ ? maxRwep
2702
+ : (result.phases?.analyze?.rwep?.threshold?.escalate ?? 90);
2703
+ const clockStarted = (result.phases?.close?.notification_actions || [])
2704
+ .some(n => n && n.clock_started_at != null);
2705
+ if (cls === "detected") {
2706
+ fail = true;
2707
+ failReasons.push(`${id}: classification=detected`);
2708
+ }
2709
+ if (cls !== "not_detected" && cls !== "clean" && rwepAdj >= cap) {
2710
+ fail = true;
2711
+ failReasons.push(`${id}: rwep=${rwepAdj} >= cap=${cap} (classification=${cls})`);
2712
+ }
2713
+ if (blockOnClock && clockStarted) {
2714
+ fail = true;
2715
+ failReasons.push(`${id}: jurisdiction clock started`);
2716
+ }
2717
+ }
2718
+
2719
+ const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
2720
+ const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
2721
+
2722
+ emit({
2723
+ verb: "ci",
2724
+ session_id: sessionId,
2725
+ playbooks_run: ids,
2726
+ summary: {
2727
+ total: results.length,
2728
+ detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
2729
+ inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
2730
+ not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
2731
+ blocked: results.filter(r => r && r.ok === false).length,
2732
+ max_rwep_observed: maxRwepObserved,
2733
+ jurisdiction_clocks_started: results
2734
+ .flatMap(r => r.phases?.close?.notification_actions || [])
2735
+ .filter(n => n && n.clock_started_at != null).length,
2736
+ verdict: fail ? "FAIL" : "PASS",
2737
+ fail_reasons: failReasons,
2738
+ },
2739
+ results,
2740
+ }, pretty);
2741
+ if (fail) {
2742
+ process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
2743
+ process.exit(2);
2744
+ }
2745
+ }
2746
+
1016
2747
  if (require.main === module) main();
1017
2748
 
1018
2749
  module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };