@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/AGENTS.md +45 -0
- package/CHANGELOG.md +120 -0
- package/README.md +30 -5
- package/bin/exceptd.js +694 -1
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +1073 -0
- package/data/playbooks/containers.json +1078 -0
- package/data/playbooks/cred-stores.json +1000 -0
- package/data/playbooks/crypto.json +1008 -0
- package/data/playbooks/framework.json +1015 -0
- package/data/playbooks/hardening.json +945 -0
- package/data/playbooks/kernel.json +796 -0
- package/data/playbooks/mcp.json +1042 -0
- package/data/playbooks/runtime.json +913 -0
- package/data/playbooks/sbom.json +1279 -0
- package/data/playbooks/secrets.json +959 -0
- package/lib/cross-ref-api.js +224 -0
- package/lib/playbook-runner.js +896 -0
- package/lib/schemas/playbook.schema.json +657 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/scanner.js +23 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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 };
|