@blamejs/exceptd-skills 0.9.5 → 0.10.1

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
@@ -24,6 +24,19 @@
24
24
  * validate-rfcs [args] Cross-check RFC catalog against Datatracker.
25
25
  * watchlist [args] Forward-watch aggregator.
26
26
  * verify Verify every skill's Ed25519 signature.
27
+ *
28
+ * Seven-phase playbook contract (govern → direct → look → detect →
29
+ * analyze → validate → close):
30
+ *
31
+ * plan List playbooks + directives for session planning.
32
+ * govern <playbook> Phase 1: load GRC context.
33
+ * direct <playbook> Phase 2: scope the investigation.
34
+ * look <playbook> Phase 3: emit artifact-collection spec for agent.
35
+ * run <playbook> Phases 4-7 (detect/analyze/validate/close) from
36
+ * agent submission JSON.
37
+ * ingest Alias for `run` matching AGENTS.md terminology.
38
+ * reattest <session> Re-run a prior session and diff evidence_hash.
39
+ *
27
40
  * help, --help, -h This help.
28
41
  * version, --version,
29
42
  * -v Print the package version.
@@ -70,6 +83,14 @@ const COMMANDS = {
70
83
  watchlist: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
71
84
  "framework-gap": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
72
85
  "framework-gap-analysis": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
86
+ // Seven-phase playbook verbs — handled in-process via lib/playbook-runner.js.
87
+ plan: null,
88
+ govern: null,
89
+ direct: null,
90
+ look: null,
91
+ run: null,
92
+ ingest: null,
93
+ reattest: null,
73
94
  };
74
95
 
75
96
  const ORCHESTRATOR_PASSTHROUGH = new Set([
@@ -78,6 +99,11 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
78
99
  "framework-gap", "framework-gap-analysis",
79
100
  ]);
80
101
 
102
+ // Seven-phase playbook verbs handled in-process (no subprocess dispatch).
103
+ const PLAYBOOK_VERBS = new Set([
104
+ "plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations",
105
+ ]);
106
+
81
107
  function readPkgVersion() {
82
108
  try {
83
109
  return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version;
@@ -121,6 +147,38 @@ Analyst:
121
147
  validate-rfcs [args] Cross-check RFC catalog vs IETF Datatracker.
122
148
  watchlist [args] Forward-watch aggregator across skills.
123
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
+
124
182
  Common:
125
183
  help This help.
126
184
  version Package version.
@@ -158,6 +216,13 @@ function main() {
158
216
  process.exit(0);
159
217
  }
160
218
 
219
+ // Seven-phase playbook verbs run in-process — they emit JSON to stdout
220
+ // rather than dispatch to a script.
221
+ if (PLAYBOOK_VERBS.has(cmd)) {
222
+ dispatchPlaybook(cmd, rest);
223
+ return;
224
+ }
225
+
161
226
  const resolver = COMMANDS[cmd];
162
227
  if (typeof resolver !== "function") {
163
228
  process.stderr.write(`exceptd: unknown command "${cmd}". Run \`exceptd help\` for the list.\n`);
@@ -181,6 +246,634 @@ function main() {
181
246
  process.exit(typeof res.status === "number" ? res.status : 1);
182
247
  }
183
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // Seven-phase playbook dispatch (in-process)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Tiny POSIX-ish argv parser. Recognised forms:
255
+ * --flag → boolean true
256
+ * --key value → string
257
+ * --key=value → string
258
+ * --repeatable v1 --repeatable v2 → array (when listed in `multi`)
259
+ * Bare positional args land in `_`. Unknown flags fall through as booleans /
260
+ * strings using the same rules so the harness stays forgiving for future
261
+ * additions without forcing a schema bump here.
262
+ */
263
+ function parseArgs(argv, opts) {
264
+ const knownBool = new Set(opts.bool || []);
265
+ const knownMulti = new Set(opts.multi || []);
266
+ const out = { _: [] };
267
+ for (let i = 0; i < argv.length; i++) {
268
+ const a = argv[i];
269
+ if (a.startsWith("--")) {
270
+ const eq = a.indexOf("=");
271
+ const key = (eq === -1 ? a.slice(2) : a.slice(2, eq));
272
+ if (eq !== -1) {
273
+ const val = a.slice(eq + 1);
274
+ if (knownMulti.has(key)) { (out[key] = out[key] || []).push(val); }
275
+ else out[key] = val;
276
+ continue;
277
+ }
278
+ if (knownBool.has(key)) { out[key] = true; continue; }
279
+ // Look ahead for a value; if next token is another flag, treat as bool.
280
+ const next = argv[i + 1];
281
+ if (next === undefined || next.startsWith("--")) {
282
+ out[key] = true;
283
+ } else {
284
+ if (knownMulti.has(key)) { (out[key] = out[key] || []).push(next); }
285
+ else out[key] = next;
286
+ i++;
287
+ }
288
+ } else {
289
+ out._.push(a);
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
295
+ function emit(obj, pretty) {
296
+ const s = pretty ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
297
+ process.stdout.write(s + "\n");
298
+ }
299
+
300
+ function emitError(msg, extra, pretty) {
301
+ const body = Object.assign({ ok: false, error: msg }, extra || {});
302
+ const s = pretty ? JSON.stringify(body, null, 2) : JSON.stringify(body);
303
+ process.stderr.write(s + "\n");
304
+ process.exit(1);
305
+ }
306
+
307
+ function readEvidence(evidenceFlag) {
308
+ if (!evidenceFlag) return {};
309
+ if (evidenceFlag === "-") {
310
+ const buf = fs.readFileSync(0, "utf8"); // stdin
311
+ if (!buf.trim()) return {};
312
+ return JSON.parse(buf);
313
+ }
314
+ return JSON.parse(fs.readFileSync(evidenceFlag, "utf8"));
315
+ }
316
+
317
+ function loadRunner() {
318
+ return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
319
+ }
320
+
321
+ function firstDirectiveId(runner, playbookId) {
322
+ const pb = runner.loadPlaybook(playbookId);
323
+ if (!pb.directives || !pb.directives.length) {
324
+ throw new Error(`Playbook ${playbookId} has no directives.`);
325
+ }
326
+ return pb.directives[0].id;
327
+ }
328
+
329
+ function dispatchPlaybook(cmd, argv) {
330
+ // Per-verb --help / -h before any positional-arg validation so users always
331
+ // get usage text instead of an error about missing arguments.
332
+ if (argv.includes("--help") || argv.includes("-h")) {
333
+ printPlaybookVerbHelp(cmd);
334
+ process.exit(0);
335
+ }
336
+
337
+ const args = parseArgs(argv, {
338
+ bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives"],
339
+ multi: ["playbook"],
340
+ });
341
+ const pretty = !!args.pretty;
342
+ const runOpts = {
343
+ airGap: !!args["air-gap"],
344
+ forceStale: !!args["force-stale"],
345
+ };
346
+ 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;
349
+
350
+ let runner;
351
+ try {
352
+ runner = loadRunner();
353
+ } catch (e) {
354
+ emitError(`Failed to load lib/playbook-runner.js: ${e.message}`, null, pretty);
355
+ return;
356
+ }
357
+
358
+ try {
359
+ switch (cmd) {
360
+ case "plan": return cmdPlan(runner, args, runOpts, pretty);
361
+ case "govern": return cmdGovern(runner, args, runOpts, pretty);
362
+ case "direct": return cmdDirect(runner, args, pretty);
363
+ case "look": return cmdLook(runner, args, runOpts, pretty);
364
+ case "run": return cmdRun(runner, args, runOpts, pretty);
365
+ case "ingest": return cmdIngest(runner, args, runOpts, pretty);
366
+ case "reattest": return cmdReattest(runner, args, runOpts, pretty);
367
+ case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
368
+ }
369
+ } catch (e) {
370
+ emitError(e.message, { verb: cmd }, pretty);
371
+ }
372
+ }
373
+
374
+ function printPlaybookVerbHelp(verb) {
375
+ const cmds = {
376
+ plan: `plan — list playbooks + directives, grouped by scope.
377
+
378
+ Flags:
379
+ --playbook <id> ... Filter to one or more playbook IDs.
380
+ --scope <type> Filter by scope: system | code | service | cross-cutting | all
381
+ --flat Disable grouped-by-scope output; emit flat list.
382
+ --directives Include directive id + title + applies_to per playbook.
383
+ --session-id <id> Reuse a specific session ID for the planning output.
384
+ --mode <m> Investigation mode forwarded into govern.
385
+ --pretty Indented JSON output.`,
386
+ govern: `govern <playbook> — phase 1, load GRC context for a playbook.
387
+
388
+ Args / flags:
389
+ <playbook> Playbook ID. Required positional.
390
+ --directive <id> Specific directive (default: first one).
391
+ --mode <m> Investigation mode forwarded into govern policy.
392
+ --air-gap Honor _meta.air_gap_mode + air_gap_alternative paths.
393
+ --pretty Indented JSON output.
394
+
395
+ Output: jurisdiction_obligations, theater_fingerprints, framework_context, skill_preload.`,
396
+ direct: `direct <playbook> — phase 2, threat context + skill chain + token budget.
397
+
398
+ Args / flags:
399
+ <playbook> Required positional.
400
+ --directive <id> Specific directive (default: first one).
401
+ --pretty Indented JSON output.`,
402
+ look: `look <playbook> — phase 3, artifact-collection spec the host AI executes.
403
+
404
+ Args / flags:
405
+ <playbook> Required positional.
406
+ --directive <id> Specific directive (default: first one).
407
+ --air-gap Honor air_gap_alternative paths.
408
+ --pretty Indented JSON output.
409
+
410
+ Output includes a 'preconditions' array — the host AI MUST verify each
411
+ precondition with its own probes and declare results back in the submission as:
412
+ { "precondition_checks": { "<id>": true | false } }
413
+ The runner refuses the run if a precondition with on_fail=halt is unverified.`,
414
+ run: `run [playbook] — phases 4-7 (detect → analyze → validate → close).
415
+
416
+ Invocation modes:
417
+ run <playbook> Single playbook (explicit).
418
+ run --scope <type> Run all playbooks of that scope.
419
+ run --all Run every playbook.
420
+ run Auto-detect from cwd:
421
+ .git/ → code playbooks
422
+ /proc + os-release → system playbooks
423
+ Always includes cross-cutting playbooks.
424
+
425
+ Flags:
426
+ --directive <id> Specific directive (default: first one per playbook).
427
+ --evidence <file|-> Path to submission JSON or '-' for stdin.
428
+ Single-playbook shape:
429
+ { artifacts, signal_overrides, signals, precondition_checks }
430
+ Multi-playbook shape:
431
+ { "<playbook_id>": { artifacts, ... }, ... }
432
+ --session-id <id> Reuse a specific session ID.
433
+ --session-key <hex> HMAC sign the evidence_package with this key.
434
+ --force-stale Override the threat_currency_score < 50 hard-block.
435
+ --air-gap Honor air_gap_alternative paths.
436
+ --pretty Indented JSON output.
437
+
438
+ Attestation is persisted to .exceptd/attestations/<session_id>/ on every
439
+ successful run (single: attestation.json; multi: <playbook_id>.json).`,
440
+ ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
441
+
442
+ Flags:
443
+ --domain <id> Playbook ID (overrides submission.playbook_id).
444
+ --directive <id> Directive ID (overrides submission.directive_id).
445
+ --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
446
+ --pretty Indented JSON output.`,
447
+ reattest: `reattest <session-id> — replay a prior session and diff the evidence_hash.
448
+
449
+ Args / flags:
450
+ <session-id> Required positional. Looks under .exceptd/attestations/<id>/.
451
+ --pretty Indented JSON output.
452
+
453
+ Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
454
+ };
455
+ process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
456
+ }
457
+
458
+ function cmdPlan(runner, args, runOpts, pretty) {
459
+ let playbookIds = args.playbook
460
+ ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
461
+ : null;
462
+ // --scope filters playbook list by _meta.scope.
463
+ if (!playbookIds && args.scope) {
464
+ playbookIds = filterPlaybooksByScope(runner, args.scope);
465
+ }
466
+ const plan = runner.plan({
467
+ playbookIds: playbookIds || undefined,
468
+ mode: runOpts.mode,
469
+ session_id: runOpts.session_id,
470
+ });
471
+ // Default UX: group by scope unless --flat or a filter was applied.
472
+ if (!args.flat && !playbookIds) {
473
+ plan.grouped_by_scope = groupPlaybooksByScope(plan.playbooks);
474
+ plan.scope_summary = Object.fromEntries(
475
+ Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
476
+ );
477
+ }
478
+ // --directives expands each playbook entry with its directive id + title +
479
+ // applies_to so operators / AIs can pick a specific directive without
480
+ // grepping playbook source.
481
+ if (args.directives) {
482
+ for (const pb of plan.playbooks) {
483
+ const full = runner.loadPlaybook(pb.id);
484
+ pb.directives = full.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }));
485
+ }
486
+ }
487
+ emit(plan, pretty);
488
+ }
489
+
490
+ function filterPlaybooksByScope(runner, scope) {
491
+ const ids = runner.listPlaybooks();
492
+ return ids.filter(id => {
493
+ try {
494
+ const pb = runner.loadPlaybook(id);
495
+ return scope === "all" || pb._meta.scope === scope;
496
+ } catch { return false; }
497
+ });
498
+ }
499
+
500
+ function groupPlaybooksByScope(playbooks) {
501
+ const groups = {};
502
+ for (const pb of playbooks) {
503
+ const scope = pb.scope || pb._meta?.scope || "unscoped";
504
+ (groups[scope] = groups[scope] || []).push(pb.id);
505
+ }
506
+ return groups;
507
+ }
508
+
509
+ /**
510
+ * Auto-detect which scopes apply to the cwd. Returns an array of scope strings.
511
+ * - `code` when the cwd looks like a git repo
512
+ * - `system` when /proc + /etc/os-release exist (Linux host)
513
+ * - `service` always included as advisory — service investigations don't
514
+ * depend on cwd; the operator/AI decides whether to run them
515
+ *
516
+ * Returns at minimum `['cross-cutting']` so framework correlation can always
517
+ * run after other findings land.
518
+ */
519
+ function detectScopes() {
520
+ const detected = [];
521
+ if (fs.existsSync(path.join(process.cwd(), ".git"))) detected.push("code");
522
+ if (fs.existsSync("/proc") && fs.existsSync("/etc/os-release")) detected.push("system");
523
+ // service playbooks need explicit invocation — they have side effects
524
+ // (probing remote endpoints) so we don't auto-include them.
525
+ return detected.length ? detected : ["cross-cutting"];
526
+ }
527
+
528
+ function cmdGovern(runner, args, runOpts, pretty) {
529
+ const playbookId = args._[0];
530
+ if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
531
+ const pb = runner.loadPlaybook(playbookId);
532
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
533
+ if (!directiveId) return emitError(`govern: playbook ${playbookId} has no directives.`, null, pretty);
534
+ emit(runner.govern(playbookId, directiveId, runOpts), pretty);
535
+ }
536
+
537
+ function cmdDirect(runner, args, pretty) {
538
+ const playbookId = args._[0];
539
+ if (!playbookId) return emitError("direct: missing <playbookId> positional argument.", null, pretty);
540
+ const pb = runner.loadPlaybook(playbookId);
541
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
542
+ if (!directiveId) return emitError(`direct: playbook ${playbookId} has no directives.`, null, pretty);
543
+ emit(runner.direct(playbookId, directiveId), pretty);
544
+ }
545
+
546
+ function cmdLook(runner, args, runOpts, pretty) {
547
+ const playbookId = args._[0];
548
+ if (!playbookId) return emitError("look: missing <playbookId> positional argument.", null, pretty);
549
+ const pb = runner.loadPlaybook(playbookId);
550
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
551
+ if (!directiveId) return emitError(`look: playbook ${playbookId} has no directives.`, null, pretty);
552
+ emit(runner.look(playbookId, directiveId, runOpts), pretty);
553
+ }
554
+
555
+ function cmdRun(runner, args, runOpts, pretty) {
556
+ const positional = args._[0];
557
+
558
+ // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
559
+ // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
560
+ // from the cwd.
561
+ if (!positional && (args.all || args.scope)) {
562
+ let ids;
563
+ if (args.all) {
564
+ ids = runner.listPlaybooks();
565
+ } else {
566
+ ids = filterPlaybooksByScope(runner, args.scope);
567
+ }
568
+ return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
569
+ }
570
+ if (!positional && !args.all && !args.scope) {
571
+ const scopes = detectScopes();
572
+ const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
573
+ const unique = [...new Set(ids)];
574
+ if (unique.length === 0) {
575
+ return emitError("run: no playbook resolved. Pass <playbookId>, --scope <type>, or --all.", null, pretty);
576
+ }
577
+ return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
578
+ }
579
+
580
+ // Single-playbook path (existing behavior).
581
+ const playbookId = positional;
582
+ const pb = runner.loadPlaybook(playbookId);
583
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
584
+ if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
585
+
586
+ let submission = {};
587
+ if (args.evidence) {
588
+ try {
589
+ submission = readEvidence(args.evidence);
590
+ } catch (e) {
591
+ return emitError(`run: failed to read evidence: ${e.message}`, { evidence: args.evidence }, pretty);
592
+ }
593
+ }
594
+
595
+ // Lift precondition_checks out of the submission into runOpts so the agent
596
+ // can declare host-platform / tool-availability facts in one JSON blob.
597
+ if (submission.precondition_checks) {
598
+ runOpts.precondition_checks = submission.precondition_checks;
599
+ }
600
+
601
+ const result = runner.run(playbookId, directiveId, submission, runOpts);
602
+
603
+ // Persist attestation for reattest cycles when the run succeeded.
604
+ if (result && result.ok && result.session_id) {
605
+ try {
606
+ const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
607
+ fs.mkdirSync(dir, { recursive: true });
608
+ fs.writeFileSync(
609
+ path.join(dir, "attestation.json"),
610
+ JSON.stringify({
611
+ session_id: result.session_id,
612
+ playbook_id: result.playbook_id,
613
+ directive_id: result.directive_id,
614
+ evidence_hash: result.evidence_hash,
615
+ submission,
616
+ run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
617
+ captured_at: new Date().toISOString(),
618
+ }, null, 2)
619
+ );
620
+ } catch { /* non-fatal — attestation persistence is best-effort */ }
621
+ }
622
+
623
+ if (result && result.ok === false) {
624
+ process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
625
+ process.exit(1);
626
+ }
627
+ emit(result, pretty);
628
+ }
629
+
630
+ /**
631
+ * Multi-playbook run. Iterates `ids` (already filtered by scope or auto-detect),
632
+ * runs each through runner.run with a shared session_id, persists each
633
+ * attestation under .exceptd/attestations/<session_id>/<playbook_id>.json, and
634
+ * emits a single aggregate bundle. Refuses if no evidence is provided (the
635
+ * host AI MUST submit observations per playbook — the engine can't synthesize them).
636
+ *
637
+ * Evidence shape for multi-run: { <playbook_id>: { artifacts, signal_overrides, signals, precondition_checks } }
638
+ * Falls back to running every playbook with empty evidence (engine returns
639
+ * inconclusive findings + visibility gaps) when no --evidence is given.
640
+ */
641
+ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
642
+ const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
643
+ runOpts.session_id = sessionId;
644
+
645
+ let bundle = {};
646
+ if (args.evidence) {
647
+ try { bundle = readEvidence(args.evidence); } catch (e) {
648
+ return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
649
+ }
650
+ }
651
+
652
+ const results = [];
653
+ for (const id of ids) {
654
+ const pb = runner.loadPlaybook(id);
655
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
656
+ if (!directiveId) {
657
+ results.push({ playbook_id: id, ok: false, error: "no directives" });
658
+ continue;
659
+ }
660
+ const submission = bundle[id] || {};
661
+ const perRunOpts = { ...runOpts };
662
+ if (submission.precondition_checks) perRunOpts.precondition_checks = submission.precondition_checks;
663
+
664
+ const result = runner.run(id, directiveId, submission, perRunOpts);
665
+
666
+ // Persist per-playbook attestation under the shared session.
667
+ if (result && result.ok) {
668
+ try {
669
+ const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
670
+ fs.mkdirSync(dir, { recursive: true });
671
+ fs.writeFileSync(
672
+ path.join(dir, `${id}.json`),
673
+ JSON.stringify({
674
+ session_id: sessionId,
675
+ playbook_id: id,
676
+ directive_id: directiveId,
677
+ evidence_hash: result.evidence_hash,
678
+ submission,
679
+ run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
680
+ captured_at: new Date().toISOString(),
681
+ }, null, 2)
682
+ );
683
+ } catch { /* non-fatal */ }
684
+ }
685
+ results.push(result);
686
+ }
687
+
688
+ emit({
689
+ ok: results.every(r => r.ok !== false),
690
+ session_id: sessionId,
691
+ trigger: meta.trigger,
692
+ detected_scopes: meta.detected_scopes || null,
693
+ playbooks_run: ids,
694
+ summary: {
695
+ total: results.length,
696
+ succeeded: results.filter(r => r.ok !== false).length,
697
+ blocked: results.filter(r => r.ok === false).length,
698
+ detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
699
+ inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
700
+ },
701
+ results,
702
+ }, pretty);
703
+ }
704
+
705
+ function cmdIngest(runner, args, runOpts, pretty) {
706
+ // `ingest` matches the AGENTS.md ingest contract. The submission JSON may
707
+ // carry playbook_id + directive_id; --domain/--directive flags override.
708
+ let submission = {};
709
+ if (args.evidence) {
710
+ try {
711
+ submission = readEvidence(args.evidence);
712
+ } catch (e) {
713
+ return emitError(`ingest: failed to read evidence: ${e.message}`, { evidence: args.evidence }, pretty);
714
+ }
715
+ }
716
+ const playbookId = args.domain || submission.playbook_id || submission.domain;
717
+ if (!playbookId) return emitError("ingest: no playbook resolved — pass --domain <id> or include playbook_id in evidence JSON.", null, pretty);
718
+ const pb = runner.loadPlaybook(playbookId);
719
+ const directiveId = args.directive
720
+ || submission.directive_id
721
+ || (pb.directives[0] && pb.directives[0].id);
722
+ if (!directiveId) return emitError(`ingest: playbook ${playbookId} has no directives.`, null, pretty);
723
+
724
+ // Strip the routing keys so the runner only sees the contract shape it expects.
725
+ const cleanedSubmission = {
726
+ artifacts: submission.artifacts || {},
727
+ signal_overrides: submission.signal_overrides || {},
728
+ signals: submission.signals || {},
729
+ };
730
+
731
+ if (submission.precondition_checks) {
732
+ runOpts.precondition_checks = submission.precondition_checks;
733
+ }
734
+
735
+ const result = runner.run(playbookId, directiveId, cleanedSubmission, runOpts);
736
+
737
+ if (result && result.ok && result.session_id) {
738
+ try {
739
+ const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
740
+ fs.mkdirSync(dir, { recursive: true });
741
+ fs.writeFileSync(
742
+ path.join(dir, "attestation.json"),
743
+ JSON.stringify({
744
+ session_id: result.session_id,
745
+ playbook_id: result.playbook_id,
746
+ directive_id: result.directive_id,
747
+ evidence_hash: result.evidence_hash,
748
+ submission: cleanedSubmission,
749
+ run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
750
+ captured_at: new Date().toISOString(),
751
+ }, null, 2)
752
+ );
753
+ } catch { /* non-fatal */ }
754
+ }
755
+
756
+ if (result && result.ok === false) {
757
+ process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
758
+ process.exit(1);
759
+ }
760
+ emit(result, pretty);
761
+ }
762
+
763
+ function cmdReattest(runner, args, runOpts, pretty) {
764
+ const sessionId = args._[0];
765
+ if (!sessionId) return emitError("reattest: missing <session-id> positional argument.", null, pretty);
766
+ const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
767
+ const attFile = path.join(dir, "attestation.json");
768
+ if (!fs.existsSync(attFile)) {
769
+ return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
770
+ }
771
+ let prior;
772
+ try {
773
+ prior = JSON.parse(fs.readFileSync(attFile, "utf8"));
774
+ } catch (e) {
775
+ return emitError(`reattest: failed to parse prior attestation: ${e.message}`, { session_id: sessionId }, pretty);
776
+ }
777
+
778
+ // Re-run with an empty submission against the same playbook/directive.
779
+ // Preserve only precondition_checks from the prior submission so the runner
780
+ // doesn't halt on host-environment guards (the reattest is about evidence
781
+ // drift, not re-verifying that the host is still Linux etc.).
782
+ const emptySubmission = { artifacts: {}, signal_overrides: {}, signals: {} };
783
+ const replayOpts = Object.assign({}, runOpts, {
784
+ airGap: !!(prior.run_opts && prior.run_opts.airGap) || runOpts.airGap,
785
+ forceStale: true, // bypass currency block on reattest — drift comparison is the point
786
+ });
787
+ if (prior.submission && prior.submission.precondition_checks) {
788
+ replayOpts.precondition_checks = prior.submission.precondition_checks;
789
+ } else {
790
+ // Fallback: synthesise pass-through preconditions from the playbook so the
791
+ // replay isn't blocked when the operator didn't originally pass them.
792
+ try {
793
+ const pb = runner.loadPlaybook(prior.playbook_id);
794
+ const synth = {};
795
+ for (const pc of (pb._meta && pb._meta.preconditions) || []) synth[pc.id] = true;
796
+ replayOpts.precondition_checks = synth;
797
+ } catch { /* ignore */ }
798
+ }
799
+ const replay = runner.run(prior.playbook_id, prior.directive_id, emptySubmission, replayOpts);
800
+
801
+ if (!replay || replay.ok === false) {
802
+ return emitError(`reattest: replay failed: ${replay && replay.reason || "unknown"}`, { replay }, pretty);
803
+ }
804
+
805
+ const priorHash = prior.evidence_hash;
806
+ const newHash = replay.evidence_hash;
807
+ let status;
808
+ if (priorHash === newHash) {
809
+ status = "unchanged";
810
+ } else {
811
+ // If the original was a detected finding and the replay no longer detects,
812
+ // call it "resolved"; otherwise "drifted".
813
+ const priorClassification = (prior.submission && prior.submission.signals
814
+ && prior.submission.signals.detection_classification) || null;
815
+ const newClassification = replay.phases && replay.phases.detect && replay.phases.detect.classification;
816
+ if (priorClassification === "detected" && newClassification !== "detected") {
817
+ status = "resolved";
818
+ } else {
819
+ status = "drifted";
820
+ }
821
+ }
822
+
823
+ emit({
824
+ ok: true,
825
+ verb: "reattest",
826
+ session_id: sessionId,
827
+ playbook_id: prior.playbook_id,
828
+ directive_id: prior.directive_id,
829
+ status,
830
+ prior_evidence_hash: priorHash,
831
+ replay_evidence_hash: newHash,
832
+ prior_captured_at: prior.captured_at,
833
+ replayed_at: new Date().toISOString(),
834
+ replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
835
+ replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
836
+ }, pretty);
837
+ }
838
+
839
+ function cmdListAttestations(runner, args, runOpts, pretty) {
840
+ const root = path.join(process.cwd(), ".exceptd", "attestations");
841
+ if (!fs.existsSync(root)) {
842
+ return emit({ ok: true, attestations: [], note: `No attestations directory at ${path.relative(process.cwd(), root)}` }, pretty);
843
+ }
844
+ const sessions = fs.readdirSync(root, { withFileTypes: true })
845
+ .filter(d => d.isDirectory())
846
+ .map(d => d.name);
847
+
848
+ const entries = [];
849
+ for (const sid of sessions) {
850
+ const sdir = path.join(root, sid);
851
+ const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json"));
852
+ for (const f of files) {
853
+ try {
854
+ const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
855
+ // Apply --playbook filter if supplied.
856
+ if (args.playbook && j.playbook_id !== args.playbook) continue;
857
+ entries.push({
858
+ session_id: sid,
859
+ playbook_id: j.playbook_id,
860
+ directive_id: j.directive_id,
861
+ evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
862
+ captured_at: j.captured_at || null,
863
+ file: path.relative(process.cwd(), path.join(sdir, f)),
864
+ });
865
+ } catch { /* skip malformed */ }
866
+ }
867
+ }
868
+ entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
869
+ emit({
870
+ ok: true,
871
+ attestations: entries,
872
+ count: entries.length,
873
+ filter: { playbook: args.playbook || null },
874
+ }, pretty);
875
+ }
876
+
184
877
  if (require.main === module) main();
185
878
 
186
- module.exports = { COMMANDS, PKG_ROOT };
879
+ module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };