@blamejs/exceptd-skills 0.10.0 → 0.10.2
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/AGENTS.md +51 -0
- package/CHANGELOG.md +95 -0
- package/bin/exceptd.js +445 -12
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +400 -90
- package/data/playbooks/containers.json +406 -94
- package/data/playbooks/cred-stores.json +374 -89
- package/data/playbooks/crypto-codebase.json +1387 -0
- package/data/playbooks/crypto.json +369 -87
- package/data/playbooks/framework.json +376 -86
- package/data/playbooks/hardening.json +357 -84
- package/data/playbooks/kernel.json +325 -78
- package/data/playbooks/library-author.json +1792 -0
- package/data/playbooks/mcp.json +407 -92
- package/data/playbooks/runtime.json +345 -81
- package/data/playbooks/sbom.json +497 -111
- package/data/playbooks/secrets.json +352 -83
- package/lib/framework-gap.js +17 -1
- package/lib/playbook-runner.js +126 -11
- package/lib/schemas/playbook.schema.json +5 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/bin/exceptd.js
CHANGED
|
@@ -101,7 +101,7 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
|
|
|
101
101
|
|
|
102
102
|
// Seven-phase playbook verbs handled in-process (no subprocess dispatch).
|
|
103
103
|
const PLAYBOOK_VERBS = new Set([
|
|
104
|
-
"plan", "govern", "direct", "look", "run", "ingest", "reattest",
|
|
104
|
+
"plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations",
|
|
105
105
|
]);
|
|
106
106
|
|
|
107
107
|
function readPkgVersion() {
|
|
@@ -149,8 +149,9 @@ Analyst:
|
|
|
149
149
|
|
|
150
150
|
Playbook runner — seven-phase contract
|
|
151
151
|
(govern → direct → look → detect → analyze → validate → close):
|
|
152
|
-
plan [--playbook id]... List playbooks + directives
|
|
153
|
-
[--
|
|
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]
|
|
154
155
|
govern <playbook> Phase 1: GRC context (jurisdictions, theater,
|
|
155
156
|
framework gaps, skill_preload).
|
|
156
157
|
[--directive id] [--mode m] [--air-gap]
|
|
@@ -160,8 +161,14 @@ Playbook runner — seven-phase contract
|
|
|
160
161
|
look <playbook> Phase 3: artifact-collection spec the host AI
|
|
161
162
|
should execute.
|
|
162
163
|
[--directive id] [--air-gap]
|
|
163
|
-
run
|
|
164
|
-
|
|
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
|
|
165
172
|
[--directive id] [--evidence file|-]
|
|
166
173
|
[--session-id id] [--session-key hex]
|
|
167
174
|
[--force-stale] [--air-gap]
|
|
@@ -320,8 +327,15 @@ function firstDirectiveId(runner, playbookId) {
|
|
|
320
327
|
}
|
|
321
328
|
|
|
322
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
|
+
|
|
323
337
|
const args = parseArgs(argv, {
|
|
324
|
-
bool: ["pretty", "air-gap", "force-stale"],
|
|
338
|
+
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives", "ci", "latest", "diff-from-latest"],
|
|
325
339
|
multi: ["playbook"],
|
|
326
340
|
});
|
|
327
341
|
const pretty = !!args.pretty;
|
|
@@ -350,24 +364,190 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
350
364
|
case "run": return cmdRun(runner, args, runOpts, pretty);
|
|
351
365
|
case "ingest": return cmdIngest(runner, args, runOpts, pretty);
|
|
352
366
|
case "reattest": return cmdReattest(runner, args, runOpts, pretty);
|
|
367
|
+
case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
|
|
353
368
|
}
|
|
354
369
|
} catch (e) {
|
|
355
370
|
emitError(e.message, { verb: cmd }, pretty);
|
|
356
371
|
}
|
|
357
372
|
}
|
|
358
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
|
+
--vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
|
|
433
|
+
not_affected | resolved | false_positive (CycloneDX)
|
|
434
|
+
or not_affected | fixed (OpenVEX) drop out of
|
|
435
|
+
analyze.matched_cves. The disposition is preserved
|
|
436
|
+
under analyze.vex.dropped_cves.
|
|
437
|
+
--diff-from-latest Compare evidence_hash against the most recent prior
|
|
438
|
+
attestation for the same playbook in
|
|
439
|
+
.exceptd/attestations/. Emits status: unchanged | drifted.
|
|
440
|
+
--ci Machine-readable verdict for CI gates. Exits non-zero
|
|
441
|
+
(code 2) when phases.detect.classification === 'detected'
|
|
442
|
+
OR phases.analyze.rwep.adjusted >= rwep_threshold.escalate.
|
|
443
|
+
Logs PASS/FAIL reason to stderr.
|
|
444
|
+
--session-id <id> Reuse a specific session ID.
|
|
445
|
+
--session-key <hex> HMAC sign the evidence_package with this key.
|
|
446
|
+
--force-stale Override the threat_currency_score < 50 hard-block.
|
|
447
|
+
--air-gap Honor air_gap_alternative paths.
|
|
448
|
+
--pretty Indented JSON output.
|
|
449
|
+
|
|
450
|
+
Attestation is persisted to .exceptd/attestations/<session_id>/ on every
|
|
451
|
+
successful run (single: attestation.json; multi: <playbook_id>.json).`,
|
|
452
|
+
ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
|
|
453
|
+
|
|
454
|
+
Flags:
|
|
455
|
+
--domain <id> Playbook ID (overrides submission.playbook_id).
|
|
456
|
+
--directive <id> Directive ID (overrides submission.directive_id).
|
|
457
|
+
--evidence <file|-> Submission JSON. May include playbook_id/directive_id.
|
|
458
|
+
--pretty Indented JSON output.`,
|
|
459
|
+
reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
|
|
460
|
+
|
|
461
|
+
Args / flags:
|
|
462
|
+
<session-id> Looks under .exceptd/attestations/<id>/attestation.json.
|
|
463
|
+
--latest Find the most-recent attestation automatically.
|
|
464
|
+
--playbook <id> Restrict --latest to a specific playbook.
|
|
465
|
+
--since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
|
|
466
|
+
--pretty Indented JSON output.
|
|
467
|
+
|
|
468
|
+
Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
|
|
469
|
+
"list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
|
|
470
|
+
|
|
471
|
+
Args / flags:
|
|
472
|
+
--playbook <id> Filter to one playbook.
|
|
473
|
+
--pretty Indented JSON output.
|
|
474
|
+
|
|
475
|
+
Lists every attestation under .exceptd/attestations/<session_id>/, sorted
|
|
476
|
+
newest-first, with truncated evidence_hash + capture timestamp + file path.`,
|
|
477
|
+
};
|
|
478
|
+
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
479
|
+
}
|
|
480
|
+
|
|
359
481
|
function cmdPlan(runner, args, runOpts, pretty) {
|
|
360
|
-
|
|
482
|
+
let playbookIds = args.playbook
|
|
361
483
|
? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
|
|
362
484
|
: null;
|
|
485
|
+
// --scope filters playbook list by _meta.scope.
|
|
486
|
+
if (!playbookIds && args.scope) {
|
|
487
|
+
playbookIds = filterPlaybooksByScope(runner, args.scope);
|
|
488
|
+
}
|
|
363
489
|
const plan = runner.plan({
|
|
364
490
|
playbookIds: playbookIds || undefined,
|
|
365
491
|
mode: runOpts.mode,
|
|
366
492
|
session_id: runOpts.session_id,
|
|
367
493
|
});
|
|
494
|
+
// Default UX: group by scope unless --flat or a filter was applied.
|
|
495
|
+
if (!args.flat && !playbookIds) {
|
|
496
|
+
plan.grouped_by_scope = groupPlaybooksByScope(plan.playbooks);
|
|
497
|
+
plan.scope_summary = Object.fromEntries(
|
|
498
|
+
Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
|
|
499
|
+
);
|
|
500
|
+
}
|
|
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.
|
|
504
|
+
if (args.directives) {
|
|
505
|
+
for (const pb of plan.playbooks) {
|
|
506
|
+
const full = runner.loadPlaybook(pb.id);
|
|
507
|
+
pb.directives = full.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
368
510
|
emit(plan, pretty);
|
|
369
511
|
}
|
|
370
512
|
|
|
513
|
+
function filterPlaybooksByScope(runner, scope) {
|
|
514
|
+
const ids = runner.listPlaybooks();
|
|
515
|
+
return ids.filter(id => {
|
|
516
|
+
try {
|
|
517
|
+
const pb = runner.loadPlaybook(id);
|
|
518
|
+
return scope === "all" || pb._meta.scope === scope;
|
|
519
|
+
} catch { return false; }
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function groupPlaybooksByScope(playbooks) {
|
|
524
|
+
const groups = {};
|
|
525
|
+
for (const pb of playbooks) {
|
|
526
|
+
const scope = pb.scope || pb._meta?.scope || "unscoped";
|
|
527
|
+
(groups[scope] = groups[scope] || []).push(pb.id);
|
|
528
|
+
}
|
|
529
|
+
return groups;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Auto-detect which scopes apply to the cwd. Returns an array of scope strings.
|
|
534
|
+
* - `code` when the cwd looks like a git repo
|
|
535
|
+
* - `system` when /proc + /etc/os-release exist (Linux host)
|
|
536
|
+
* - `service` always included as advisory — service investigations don't
|
|
537
|
+
* depend on cwd; the operator/AI decides whether to run them
|
|
538
|
+
*
|
|
539
|
+
* Returns at minimum `['cross-cutting']` so framework correlation can always
|
|
540
|
+
* run after other findings land.
|
|
541
|
+
*/
|
|
542
|
+
function detectScopes() {
|
|
543
|
+
const detected = [];
|
|
544
|
+
if (fs.existsSync(path.join(process.cwd(), ".git"))) detected.push("code");
|
|
545
|
+
if (fs.existsSync("/proc") && fs.existsSync("/etc/os-release")) detected.push("system");
|
|
546
|
+
// service playbooks need explicit invocation — they have side effects
|
|
547
|
+
// (probing remote endpoints) so we don't auto-include them.
|
|
548
|
+
return detected.length ? detected : ["cross-cutting"];
|
|
549
|
+
}
|
|
550
|
+
|
|
371
551
|
function cmdGovern(runner, args, runOpts, pretty) {
|
|
372
552
|
const playbookId = args._[0];
|
|
373
553
|
if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
|
|
@@ -396,8 +576,32 @@ function cmdLook(runner, args, runOpts, pretty) {
|
|
|
396
576
|
}
|
|
397
577
|
|
|
398
578
|
function cmdRun(runner, args, runOpts, pretty) {
|
|
399
|
-
const
|
|
400
|
-
|
|
579
|
+
const positional = args._[0];
|
|
580
|
+
|
|
581
|
+
// Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
|
|
582
|
+
// a bare `exceptd run` (no positional, no flags) which auto-detects scopes
|
|
583
|
+
// from the cwd.
|
|
584
|
+
if (!positional && (args.all || args.scope)) {
|
|
585
|
+
let ids;
|
|
586
|
+
if (args.all) {
|
|
587
|
+
ids = runner.listPlaybooks();
|
|
588
|
+
} else {
|
|
589
|
+
ids = filterPlaybooksByScope(runner, args.scope);
|
|
590
|
+
}
|
|
591
|
+
return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
|
|
592
|
+
}
|
|
593
|
+
if (!positional && !args.all && !args.scope) {
|
|
594
|
+
const scopes = detectScopes();
|
|
595
|
+
const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
|
596
|
+
const unique = [...new Set(ids)];
|
|
597
|
+
if (unique.length === 0) {
|
|
598
|
+
return emitError("run: no playbook resolved. Pass <playbookId>, --scope <type>, or --all.", null, pretty);
|
|
599
|
+
}
|
|
600
|
+
return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Single-playbook path (existing behavior).
|
|
604
|
+
const playbookId = positional;
|
|
401
605
|
const pb = runner.loadPlaybook(playbookId);
|
|
402
606
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
403
607
|
if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
|
|
@@ -417,6 +621,19 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
417
621
|
runOpts.precondition_checks = submission.precondition_checks;
|
|
418
622
|
}
|
|
419
623
|
|
|
624
|
+
// --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
|
|
625
|
+
// CVE ID set through to analyze() so matched_cves drops them.
|
|
626
|
+
if (args.vex) {
|
|
627
|
+
try {
|
|
628
|
+
const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
|
|
629
|
+
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
630
|
+
submission.signals = submission.signals || {};
|
|
631
|
+
submission.signals.vex_filter = [...vexSet];
|
|
632
|
+
} catch (e) {
|
|
633
|
+
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
420
637
|
const result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
421
638
|
|
|
422
639
|
// Persist attestation for reattest cycles when the run succeeded.
|
|
@@ -443,9 +660,146 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
443
660
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
444
661
|
process.exit(1);
|
|
445
662
|
}
|
|
663
|
+
|
|
664
|
+
// --diff-from-latest: compare evidence_hash against the most recent prior
|
|
665
|
+
// attestation for this playbook. Drift mode for cron baselines.
|
|
666
|
+
// We've already persisted the CURRENT attestation above, so the find must
|
|
667
|
+
// skip our session_id to get the actual prior one.
|
|
668
|
+
if (args["diff-from-latest"] && result && result.evidence_hash) {
|
|
669
|
+
const prior = findLatestAttestation({ playbookId, excludeSessionId: result.session_id });
|
|
670
|
+
if (prior) {
|
|
671
|
+
const priorHash = prior.parsed.evidence_hash;
|
|
672
|
+
result.diff_from_latest = {
|
|
673
|
+
prior_session_id: prior.parsed.session_id,
|
|
674
|
+
prior_captured_at: prior.parsed.captured_at,
|
|
675
|
+
prior_evidence_hash: priorHash,
|
|
676
|
+
new_evidence_hash: result.evidence_hash,
|
|
677
|
+
status: priorHash === result.evidence_hash ? "unchanged" : "drifted",
|
|
678
|
+
};
|
|
679
|
+
} else {
|
|
680
|
+
result.diff_from_latest = { status: "no_prior_attestation_for_playbook", playbook_id: playbookId };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// --ci: machine-readable verdict for CI gates.
|
|
685
|
+
//
|
|
686
|
+
// The detect phase classification is the host-specific signal — "is THIS
|
|
687
|
+
// environment exploitable for the catalogued CVEs". rwep.base is the
|
|
688
|
+
// worst-known catalog score for the domain, which is roughly constant
|
|
689
|
+
// regardless of the local environment; we don't fail CI on that alone or
|
|
690
|
+
// every CI run against a domain with KEV-listed catalog entries would
|
|
691
|
+
// perma-fail.
|
|
692
|
+
//
|
|
693
|
+
// Verdict:
|
|
694
|
+
// detected → FAIL (exit 2)
|
|
695
|
+
// inconclusive + rwep ≥ escalate
|
|
696
|
+
// → FAIL (the agent's evidence raised RWEP)
|
|
697
|
+
// not_detected → PASS (exit 0)
|
|
698
|
+
// inconclusive + rwep < escalate
|
|
699
|
+
// → PASS with WARN to stderr (visibility gap)
|
|
700
|
+
if (args.ci && result && result.phases) {
|
|
701
|
+
const classification = result.phases.detect && result.phases.detect.classification;
|
|
702
|
+
const rwep = result.phases.analyze && result.phases.analyze.rwep;
|
|
703
|
+
const threshold = rwep && rwep.threshold && rwep.threshold.escalate;
|
|
704
|
+
const adjusted = rwep && typeof rwep.adjusted === "number" ? rwep.adjusted : 0;
|
|
705
|
+
const escalate = typeof threshold === "number" && adjusted >= threshold;
|
|
706
|
+
|
|
707
|
+
emit(result, pretty);
|
|
708
|
+
|
|
709
|
+
if (classification === "detected") {
|
|
710
|
+
process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
|
|
711
|
+
process.exit(2);
|
|
712
|
+
}
|
|
713
|
+
if (classification === "inconclusive" && escalate) {
|
|
714
|
+
process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
|
|
715
|
+
process.exit(2);
|
|
716
|
+
}
|
|
717
|
+
if (classification === "inconclusive") {
|
|
718
|
+
process.stderr.write(`[exceptd run --ci] PASS+WARN: classification=inconclusive rwep=${adjusted} < threshold=${threshold} (visibility gap)\n`);
|
|
719
|
+
} else {
|
|
720
|
+
process.stderr.write(`[exceptd run --ci] PASS: classification=${classification} rwep=${adjusted}\n`);
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
446
725
|
emit(result, pretty);
|
|
447
726
|
}
|
|
448
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Multi-playbook run. Iterates `ids` (already filtered by scope or auto-detect),
|
|
730
|
+
* runs each through runner.run with a shared session_id, persists each
|
|
731
|
+
* attestation under .exceptd/attestations/<session_id>/<playbook_id>.json, and
|
|
732
|
+
* emits a single aggregate bundle. Refuses if no evidence is provided (the
|
|
733
|
+
* host AI MUST submit observations per playbook — the engine can't synthesize them).
|
|
734
|
+
*
|
|
735
|
+
* Evidence shape for multi-run: { <playbook_id>: { artifacts, signal_overrides, signals, precondition_checks } }
|
|
736
|
+
* Falls back to running every playbook with empty evidence (engine returns
|
|
737
|
+
* inconclusive findings + visibility gaps) when no --evidence is given.
|
|
738
|
+
*/
|
|
739
|
+
function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
740
|
+
const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
|
|
741
|
+
runOpts.session_id = sessionId;
|
|
742
|
+
|
|
743
|
+
let bundle = {};
|
|
744
|
+
if (args.evidence) {
|
|
745
|
+
try { bundle = readEvidence(args.evidence); } catch (e) {
|
|
746
|
+
return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const results = [];
|
|
751
|
+
for (const id of ids) {
|
|
752
|
+
const pb = runner.loadPlaybook(id);
|
|
753
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
754
|
+
if (!directiveId) {
|
|
755
|
+
results.push({ playbook_id: id, ok: false, error: "no directives" });
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const submission = bundle[id] || {};
|
|
759
|
+
const perRunOpts = { ...runOpts };
|
|
760
|
+
if (submission.precondition_checks) perRunOpts.precondition_checks = submission.precondition_checks;
|
|
761
|
+
|
|
762
|
+
const result = runner.run(id, directiveId, submission, perRunOpts);
|
|
763
|
+
|
|
764
|
+
// Persist per-playbook attestation under the shared session.
|
|
765
|
+
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 */ }
|
|
782
|
+
}
|
|
783
|
+
results.push(result);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
emit({
|
|
787
|
+
ok: results.every(r => r.ok !== false),
|
|
788
|
+
session_id: sessionId,
|
|
789
|
+
trigger: meta.trigger,
|
|
790
|
+
detected_scopes: meta.detected_scopes || null,
|
|
791
|
+
playbooks_run: ids,
|
|
792
|
+
summary: {
|
|
793
|
+
total: results.length,
|
|
794
|
+
succeeded: results.filter(r => r.ok !== false).length,
|
|
795
|
+
blocked: results.filter(r => r.ok === false).length,
|
|
796
|
+
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
797
|
+
inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
|
|
798
|
+
},
|
|
799
|
+
results,
|
|
800
|
+
}, pretty);
|
|
801
|
+
}
|
|
802
|
+
|
|
449
803
|
function cmdIngest(runner, args, runOpts, pretty) {
|
|
450
804
|
// `ingest` matches the AGENTS.md ingest contract. The submission JSON may
|
|
451
805
|
// carry playbook_id + directive_id; --domain/--directive flags override.
|
|
@@ -504,11 +858,52 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
504
858
|
emit(result, pretty);
|
|
505
859
|
}
|
|
506
860
|
|
|
861
|
+
/**
|
|
862
|
+
* Find the latest attestation file under .exceptd/attestations/.
|
|
863
|
+
* Filters: optional playbook ID and optional "since" ISO timestamp.
|
|
864
|
+
* Returns { sessionId, playbookId, file, parsed } or null.
|
|
865
|
+
*/
|
|
866
|
+
function findLatestAttestation(opts = {}) {
|
|
867
|
+
const root = path.join(process.cwd(), ".exceptd", "attestations");
|
|
868
|
+
if (!fs.existsSync(root)) return null;
|
|
869
|
+
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
870
|
+
.filter(d => d.isDirectory())
|
|
871
|
+
.map(d => d.name);
|
|
872
|
+
const candidates = [];
|
|
873
|
+
for (const sid of sessions) {
|
|
874
|
+
const sdir = path.join(root, sid);
|
|
875
|
+
for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
|
|
876
|
+
try {
|
|
877
|
+
const p = path.join(sdir, f);
|
|
878
|
+
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
879
|
+
if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
|
|
880
|
+
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
881
|
+
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
882
|
+
candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
|
|
883
|
+
} catch { /* skip malformed */ }
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
887
|
+
return candidates[0] || null;
|
|
888
|
+
}
|
|
889
|
+
|
|
507
890
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
508
|
-
|
|
509
|
-
|
|
891
|
+
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
892
|
+
// without requiring the operator to know the session-id.
|
|
893
|
+
let sessionId = args._[0];
|
|
894
|
+
let attFile = null;
|
|
895
|
+
if (!sessionId && args.latest) {
|
|
896
|
+
const found = findLatestAttestation({
|
|
897
|
+
playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
|
|
898
|
+
since: args.since || null,
|
|
899
|
+
});
|
|
900
|
+
if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
|
|
901
|
+
sessionId = found.sessionId;
|
|
902
|
+
attFile = found.file;
|
|
903
|
+
}
|
|
904
|
+
if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
|
|
510
905
|
const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
|
|
511
|
-
|
|
906
|
+
if (!attFile) attFile = path.join(dir, "attestation.json");
|
|
512
907
|
if (!fs.existsSync(attFile)) {
|
|
513
908
|
return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
|
|
514
909
|
}
|
|
@@ -580,6 +975,44 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
580
975
|
}, pretty);
|
|
581
976
|
}
|
|
582
977
|
|
|
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);
|
|
982
|
+
}
|
|
983
|
+
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
984
|
+
.filter(d => d.isDirectory())
|
|
985
|
+
.map(d => d.name);
|
|
986
|
+
|
|
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) {
|
|
992
|
+
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 */ }
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
|
|
1008
|
+
emit({
|
|
1009
|
+
ok: true,
|
|
1010
|
+
attestations: entries,
|
|
1011
|
+
count: entries.length,
|
|
1012
|
+
filter: { playbook: args.playbook || null },
|
|
1013
|
+
}, pretty);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
583
1016
|
if (require.main === module) main();
|
|
584
1017
|
|
|
585
1018
|
module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-12T13:50:32.319Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 49,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "3614ad87835688365283c95a8b7af1d66f14928c07efef750c206ba2f13aab33",
|
|
8
8
|
"data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
|
|
9
9
|
"data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
|
|
10
10
|
"data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
|