@blamejs/exceptd-skills 0.11.0 → 0.11.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/CHANGELOG.md +50 -0
- package/README.md +133 -61
- package/bin/exceptd.js +337 -26
- package/data/_indexes/_meta.json +2 -2
- package/lib/playbook-runner.js +59 -2
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/index.js +52 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/bin/exceptd.js
CHANGED
|
@@ -81,6 +81,7 @@ const COMMANDS = {
|
|
|
81
81
|
"validate-cves": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
82
82
|
"validate-rfcs": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
83
83
|
watchlist: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
84
|
+
watch: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
84
85
|
"framework-gap": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
85
86
|
"framework-gap-analysis": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
86
87
|
// Seven-phase playbook verbs — handled in-process via lib/playbook-runner.js.
|
|
@@ -95,7 +96,7 @@ const COMMANDS = {
|
|
|
95
96
|
|
|
96
97
|
const ORCHESTRATOR_PASSTHROUGH = new Set([
|
|
97
98
|
"scan", "dispatch", "skill", "currency", "report",
|
|
98
|
-
"validate-cves", "validate-rfcs", "watchlist",
|
|
99
|
+
"validate-cves", "validate-rfcs", "watchlist", "watch",
|
|
99
100
|
"framework-gap", "framework-gap-analysis",
|
|
100
101
|
]);
|
|
101
102
|
|
|
@@ -275,6 +276,25 @@ Project rules: ${PKG_ROOT}/AGENTS.md
|
|
|
275
276
|
|
|
276
277
|
function main() {
|
|
277
278
|
const argv = process.argv.slice(2);
|
|
279
|
+
|
|
280
|
+
// --json-stdout-only: silence ALL stderr emissions (deprecation banners,
|
|
281
|
+
// unsigned-attestation warnings, hook output). Operators piping the JSON
|
|
282
|
+
// result through `jq` or scripting around exit codes want clean stdout
|
|
283
|
+
// exclusively. Handled here at top of main so the deprecation banner +
|
|
284
|
+
// unsigned warning are suppressed before they fire.
|
|
285
|
+
if (argv.includes("--json-stdout-only")) {
|
|
286
|
+
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
287
|
+
process.env.EXCEPTD_UNSIGNED_WARNED = "1";
|
|
288
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
289
|
+
process.stderr.write = (chunk, encoding, cb) => {
|
|
290
|
+
// Let actual error frames through (uncaught exceptions need to surface
|
|
291
|
+
// for debugging); suppress framework stderr.
|
|
292
|
+
if (typeof chunk === "string" && chunk.startsWith("Error")) return origStderrWrite(chunk, encoding, cb);
|
|
293
|
+
if (typeof cb === "function") cb();
|
|
294
|
+
return true;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
278
298
|
if (argv.length === 0) {
|
|
279
299
|
printWelcome();
|
|
280
300
|
process.exit(0);
|
|
@@ -300,8 +320,17 @@ function main() {
|
|
|
300
320
|
if (PLAYBOOK_VERBS.has(cmd)) {
|
|
301
321
|
// One-time deprecation banner per process when a legacy verb is invoked.
|
|
302
322
|
if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
|
|
323
|
+
// Mention the installed version explicitly so an operator on v0.10.x
|
|
324
|
+
// who reads "Prefer brief..." doesn't go looking for a verb that
|
|
325
|
+
// doesn't exist in their install. v0.11.0+ has the replacement; v0.10.x
|
|
326
|
+
// users see this with the explicit "upgrade to v0.11.0 first" note.
|
|
327
|
+
const ver = readPkgVersion();
|
|
328
|
+
const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
|
|
303
329
|
process.stderr.write(
|
|
304
|
-
`[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb.
|
|
330
|
+
`[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
|
|
331
|
+
(haveBrief
|
|
332
|
+
? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
|
|
333
|
+
: `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
|
|
305
334
|
`Legacy verbs remain functional through this release; they will be removed in v0.12. ` +
|
|
306
335
|
`Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
|
|
307
336
|
);
|
|
@@ -311,7 +340,21 @@ function main() {
|
|
|
311
340
|
return;
|
|
312
341
|
}
|
|
313
342
|
|
|
314
|
-
|
|
343
|
+
// v0.11.2 bug #65: `refresh --no-network` / `refresh --indexes-only` were
|
|
344
|
+
// documented as the v0.11.0 replacements for `prefetch` / `build-indexes`
|
|
345
|
+
// but the underlying refresh script doesn't know those flags. Translate
|
|
346
|
+
// here so the deprecation pointer actually works.
|
|
347
|
+
let effectiveCmd = cmd;
|
|
348
|
+
let effectiveRest = rest;
|
|
349
|
+
if (cmd === "refresh" && rest.includes("--no-network")) {
|
|
350
|
+
effectiveCmd = "prefetch";
|
|
351
|
+
effectiveRest = rest.filter(a => a !== "--no-network");
|
|
352
|
+
} else if (cmd === "refresh" && rest.includes("--indexes-only")) {
|
|
353
|
+
effectiveCmd = "build-indexes";
|
|
354
|
+
effectiveRest = rest.filter(a => a !== "--indexes-only");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const resolver = COMMANDS[effectiveCmd];
|
|
315
358
|
if (typeof resolver !== "function") {
|
|
316
359
|
// Emit a structured JSON error matching the seven-phase verbs so operators
|
|
317
360
|
// piping through `jq` get one consistent shape across the CLI surface.
|
|
@@ -329,7 +372,7 @@ function main() {
|
|
|
329
372
|
|
|
330
373
|
// Orchestrator subcommands need the subcommand name preserved as argv[0]
|
|
331
374
|
// for orchestrator/index.js's switch statement.
|
|
332
|
-
const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(
|
|
375
|
+
const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
|
|
333
376
|
const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
|
|
334
377
|
if (res.error) {
|
|
335
378
|
process.stderr.write(`exceptd: failed to run ${cmd}: ${res.error.message}\n`);
|
|
@@ -385,7 +428,13 @@ function parseArgs(argv, opts) {
|
|
|
385
428
|
}
|
|
386
429
|
|
|
387
430
|
function emit(obj, pretty) {
|
|
388
|
-
|
|
431
|
+
// v0.11.2 bug #60: when stdout is a TTY (interactive use), emit indented
|
|
432
|
+
// JSON instead of single-line — much more readable. Piped to a file or
|
|
433
|
+
// tool? Default to compact one-line JSON. --pretty forces indented
|
|
434
|
+
// regardless of TTY. --json-stdout-only is always compact.
|
|
435
|
+
const interactive = process.stdout.isTTY && !process.env.EXCEPTD_RAW_JSON;
|
|
436
|
+
const indent = pretty || (interactive && !pretty);
|
|
437
|
+
const s = indent ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
|
389
438
|
process.stdout.write(s + "\n");
|
|
390
439
|
}
|
|
391
440
|
|
|
@@ -429,9 +478,21 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
429
478
|
const args = parseArgs(argv, {
|
|
430
479
|
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
431
480
|
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
432
|
-
"force-overwrite", "no-stream", "block-on-jurisdiction-clock"
|
|
481
|
+
"force-overwrite", "no-stream", "block-on-jurisdiction-clock",
|
|
482
|
+
"json-stdout-only", "fix", "human", "json"],
|
|
433
483
|
multi: ["playbook", "format"],
|
|
434
484
|
});
|
|
485
|
+
// v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
|
|
486
|
+
// (or --pretty implies indented JSON). The v0.11.0 CHANGELOG claimed this
|
|
487
|
+
// was already done; the code in fact emitted JSON unconditionally. Now:
|
|
488
|
+
// --json or --pretty → JSON (one-line or indented respectively)
|
|
489
|
+
// --json-stdout-only → JSON, suppress stderr
|
|
490
|
+
// default → human-readable text
|
|
491
|
+
// Verbs that have their own human renderer (discover/doctor/refresh/lint
|
|
492
|
+
// /ask/attest list) continue to use it; verbs that don't yet (brief/run/
|
|
493
|
+
// ai-run/ci/attest show/export/diff/verify) fall back to indented JSON
|
|
494
|
+
// labeled as such — better than no signal.
|
|
495
|
+
args._jsonMode = !!(args.json || args.pretty || args["json-stdout-only"]);
|
|
435
496
|
const pretty = !!args.pretty;
|
|
436
497
|
const runOpts = {
|
|
437
498
|
airGap: !!args["air-gap"],
|
|
@@ -1080,6 +1141,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1080
1141
|
}
|
|
1081
1142
|
|
|
1082
1143
|
let submission = {};
|
|
1144
|
+
// v0.11.1: auto-detect piped stdin (process.stdin.isTTY === false means
|
|
1145
|
+
// something is piping into us). If no --evidence flag and stdin is a pipe,
|
|
1146
|
+
// assume `--evidence -`. Operators forgetting the flag previously got a
|
|
1147
|
+
// confusing precondition halt; now the common case "just works."
|
|
1148
|
+
if (!args.evidence && process.stdin.isTTY === false) {
|
|
1149
|
+
args.evidence = "-";
|
|
1150
|
+
}
|
|
1083
1151
|
if (args.evidence) {
|
|
1084
1152
|
try {
|
|
1085
1153
|
submission = readEvidence(args.evidence);
|
|
@@ -1098,7 +1166,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1098
1166
|
// Supports csaf-2.0 | sarif | openvex | markdown. Multiple --format flags
|
|
1099
1167
|
// produce multiple bundles in the close response under bundles_by_format.
|
|
1100
1168
|
if (args.format) {
|
|
1101
|
-
|
|
1169
|
+
// Normalize shortcut names to the runner's canonical bundle keys before
|
|
1170
|
+
// passing through. "csaf" → "csaf-2.0"; "sarif" / "openvex" / "markdown"
|
|
1171
|
+
// / "summary" stay verbatim. Anything else is rejected after the run
|
|
1172
|
+
// result is in hand (so the run still completes).
|
|
1173
|
+
const formats = (Array.isArray(args.format) ? args.format : [args.format])
|
|
1174
|
+
.map(f => f === "csaf" ? "csaf-2.0" : f);
|
|
1102
1175
|
submission.signals = submission.signals || {};
|
|
1103
1176
|
submission.signals._bundle_formats = formats;
|
|
1104
1177
|
}
|
|
@@ -1223,6 +1296,80 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1223
1296
|
return;
|
|
1224
1297
|
}
|
|
1225
1298
|
|
|
1299
|
+
// v0.11.2 bug #59 / feature #70: --format actually transforms the top-level
|
|
1300
|
+
// output. Previously it only populated close.evidence_package.bundles_by_format
|
|
1301
|
+
// and the operator still saw the full JSON. Now:
|
|
1302
|
+
// --format summary → single-line JSON digest (5 fields)
|
|
1303
|
+
// --format markdown → operator-readable markdown digest of the run
|
|
1304
|
+
// --format csaf-2.0/sarif/openvex → the corresponding bundle from close
|
|
1305
|
+
// (default — no --format) → full JSON result as before
|
|
1306
|
+
if (args.format) {
|
|
1307
|
+
const requested = Array.isArray(args.format) ? args.format[0] : args.format;
|
|
1308
|
+
const VALID = ["summary", "markdown", "csaf-2.0", "csaf", "sarif", "openvex", "json"];
|
|
1309
|
+
if (!VALID.includes(requested)) {
|
|
1310
|
+
return emitError(`run: --format "${requested}" not in accepted set ${JSON.stringify(VALID)}.`, null, pretty);
|
|
1311
|
+
}
|
|
1312
|
+
if (requested === "summary") {
|
|
1313
|
+
const cls = result.phases?.detect?.classification;
|
|
1314
|
+
const rwep = result.phases?.analyze?.rwep?.adjusted ?? 0;
|
|
1315
|
+
const blast = result.phases?.analyze?.blast_radius_score ?? 0;
|
|
1316
|
+
const cves = result.phases?.analyze?.matched_cves?.length ?? 0;
|
|
1317
|
+
const next = result.phases?.close?.feeds_into?.join(",") || "";
|
|
1318
|
+
const clocks = (result.phases?.close?.notification_actions || []).filter(n => n.clock_started_at).length;
|
|
1319
|
+
emit({
|
|
1320
|
+
ok: result.ok, playbook: result.playbook_id, session_id: result.session_id,
|
|
1321
|
+
classification: cls, rwep, blast_radius: blast, matched_cves: cves,
|
|
1322
|
+
feeds_into: next, jurisdiction_clocks: clocks, evidence_hash: result.evidence_hash,
|
|
1323
|
+
}, pretty);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (requested === "markdown") {
|
|
1327
|
+
const lines = [];
|
|
1328
|
+
lines.push(`# exceptd run: ${result.playbook_id}`);
|
|
1329
|
+
lines.push(`session-id: ${result.session_id}`);
|
|
1330
|
+
lines.push(`evidence-hash: ${result.evidence_hash}`);
|
|
1331
|
+
lines.push("");
|
|
1332
|
+
const cls = result.phases?.detect?.classification || "n/a";
|
|
1333
|
+
const rwep = result.phases?.analyze?.rwep?.adjusted ?? 0;
|
|
1334
|
+
const top = result.phases?.analyze?.rwep?.threshold?.escalate ?? "n/a";
|
|
1335
|
+
lines.push(`**Classification:** ${cls} **RWEP:** ${rwep} / ${top} **Blast radius:** ${result.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
|
|
1336
|
+
lines.push("");
|
|
1337
|
+
const cves = result.phases?.analyze?.matched_cves || [];
|
|
1338
|
+
if (cves.length) {
|
|
1339
|
+
lines.push(`## Matched CVEs (${cves.length})`);
|
|
1340
|
+
for (const c of cves) lines.push(`- **${c.cve_id}** · RWEP ${c.rwep} · KEV=${c.cisa_kev} · ${c.active_exploitation}`);
|
|
1341
|
+
lines.push("");
|
|
1342
|
+
}
|
|
1343
|
+
const rem = result.phases?.validate?.selected_remediation;
|
|
1344
|
+
if (rem) {
|
|
1345
|
+
lines.push(`## Recommended remediation`);
|
|
1346
|
+
lines.push(`**${rem.id}** (priority ${rem.priority}) — ${rem.description}`);
|
|
1347
|
+
lines.push("");
|
|
1348
|
+
}
|
|
1349
|
+
const notif = result.phases?.close?.notification_actions || [];
|
|
1350
|
+
if (notif.length) {
|
|
1351
|
+
lines.push(`## Notification clocks`);
|
|
1352
|
+
for (const n of notif) lines.push(`- ${n.obligation_ref} → deadline ${n.deadline}`);
|
|
1353
|
+
lines.push("");
|
|
1354
|
+
}
|
|
1355
|
+
const feeds = result.phases?.close?.feeds_into || [];
|
|
1356
|
+
if (feeds.length) lines.push(`**Next playbooks suggested:** ${feeds.join(", ")}`);
|
|
1357
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
// CSAF/SARIF/OpenVEX bundles live under close.evidence_package — the
|
|
1361
|
+
// runner writes them under canonical keys ("csaf-2.0", "sarif",
|
|
1362
|
+
// "openvex"). Normalize the user-supplied shortcuts.
|
|
1363
|
+
const formatNorm = requested === "csaf" ? "csaf-2.0" : requested;
|
|
1364
|
+
const bbf = result.phases?.close?.evidence_package?.bundles_by_format || {};
|
|
1365
|
+
const body = bbf[formatNorm] || result.phases?.close?.evidence_package?.bundle_body;
|
|
1366
|
+
if (body) {
|
|
1367
|
+
emit(body, pretty);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
// Fallback: full result
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1226
1373
|
emit(result, pretty);
|
|
1227
1374
|
}
|
|
1228
1375
|
|
|
@@ -2224,22 +2371,43 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2224
2371
|
const keyPath = path.join(process.cwd(), ".keys", "private.pem");
|
|
2225
2372
|
const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2226
2373
|
const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
|
|
2374
|
+
// Bug #61 (v0.11.2): signing-status missing key is a real WARNING. The
|
|
2375
|
+
// attestation pipeline writes unsigned files when this is absent, which
|
|
2376
|
+
// operators reading the attestation later cannot verify for authenticity.
|
|
2377
|
+
// The summary line must reflect this — pre-0.11.2 said "all checks green"
|
|
2378
|
+
// directly above [!!] private key MISSING. Now: it's a warning that
|
|
2379
|
+
// populates summary.warnings_count.
|
|
2227
2380
|
checks.signing = {
|
|
2228
|
-
ok:
|
|
2381
|
+
ok: present, // not green if the key is missing — operators need the nudge
|
|
2382
|
+
severity: present ? "info" : "warn",
|
|
2229
2383
|
private_key_present: present,
|
|
2230
2384
|
can_sign_attestations: present,
|
|
2231
|
-
...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` to enable attestation signing" }),
|
|
2385
|
+
...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` (or `exceptd doctor --fix`) to enable attestation signing" }),
|
|
2232
2386
|
};
|
|
2233
2387
|
} catch (e) {
|
|
2234
2388
|
checks.signing = { ok: false, error: e.message };
|
|
2235
2389
|
}
|
|
2236
2390
|
}
|
|
2237
2391
|
|
|
2238
|
-
|
|
2392
|
+
// Walk every check and split: errors (severity error/missing/fail) vs warnings
|
|
2393
|
+
// (severity warn). all_green is true ONLY when zero errors AND zero warnings.
|
|
2394
|
+
const warnList = [];
|
|
2395
|
+
const errorList = [];
|
|
2396
|
+
for (const [k, v] of Object.entries(checks)) {
|
|
2397
|
+
if (v.ok === false) errorList.push(k);
|
|
2398
|
+
else if (v.severity === "warn") warnList.push(k);
|
|
2399
|
+
}
|
|
2400
|
+
const allGreen = errorList.length === 0 && warnList.length === 0;
|
|
2239
2401
|
const out = {
|
|
2240
2402
|
verb: "doctor",
|
|
2241
2403
|
checks,
|
|
2242
|
-
summary: {
|
|
2404
|
+
summary: {
|
|
2405
|
+
all_green: allGreen,
|
|
2406
|
+
issues_count: errorList.length,
|
|
2407
|
+
warnings_count: warnList.length,
|
|
2408
|
+
failed_checks: errorList,
|
|
2409
|
+
warning_checks: warnList,
|
|
2410
|
+
},
|
|
2243
2411
|
};
|
|
2244
2412
|
|
|
2245
2413
|
if (wantJson) {
|
|
@@ -2253,7 +2421,10 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2253
2421
|
lines.push("exceptd doctor");
|
|
2254
2422
|
function mark(c, render) {
|
|
2255
2423
|
if (!c) return;
|
|
2256
|
-
|
|
2424
|
+
// Three states: ok / warn / error. Bug #61 (v0.11.2) — warn must not be
|
|
2425
|
+
// shown as ok and must count toward the summary so the bottom line
|
|
2426
|
+
// matches the visible icons above.
|
|
2427
|
+
const icon = c.ok && c.severity !== "warn" ? "[ok]" : (c.severity === "warn" ? "[!! warn]" : "[!! fail]");
|
|
2257
2428
|
lines.push(` ${icon} ${render(c)}`);
|
|
2258
2429
|
}
|
|
2259
2430
|
mark(checks.signatures, c =>
|
|
@@ -2284,9 +2455,30 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2284
2455
|
}
|
|
2285
2456
|
}
|
|
2286
2457
|
lines.push("");
|
|
2287
|
-
|
|
2458
|
+
if (allGreen) {
|
|
2459
|
+
lines.push(`summary: all checks green`);
|
|
2460
|
+
} else if (errorList.length === 0) {
|
|
2461
|
+
lines.push(`summary: ${warnList.length} warning(s) — ${warnList.join(", ")}`);
|
|
2462
|
+
} else {
|
|
2463
|
+
lines.push(`summary: ${errorList.length} fail / ${warnList.length} warn — fail: ${errorList.join(", ")}; warn: ${warnList.join(", ") || "none"}`);
|
|
2464
|
+
}
|
|
2288
2465
|
process.stdout.write(lines.join("\n") + "\n");
|
|
2289
|
-
|
|
2466
|
+
// Bug #69 (v0.11.2): --fix mode for missing private key.
|
|
2467
|
+
if (args.fix && checks.signing && !checks.signing.private_key_present) {
|
|
2468
|
+
process.stdout.write("\n[doctor --fix] generating Ed25519 keypair via `node lib/sign.js generate-keypair`...\n");
|
|
2469
|
+
const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], { stdio: "inherit", cwd: PKG_ROOT });
|
|
2470
|
+
if (r.status === 0) {
|
|
2471
|
+
process.stdout.write("[doctor --fix] keypair generated — re-run `exceptd doctor` to confirm.\n");
|
|
2472
|
+
} else {
|
|
2473
|
+
process.stdout.write(`[doctor --fix] generation failed (exit=${r.status}); run \`node lib/sign.js generate-keypair\` manually.\n`);
|
|
2474
|
+
process.exitCode = 1;
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
if (errorList.length > 0) process.exitCode = 1;
|
|
2479
|
+
// Warnings alone do NOT force exit 1 — CI gates use exit 0 to mean "ran
|
|
2480
|
+
// successfully" even with informational warnings. Operators reading the
|
|
2481
|
+
// visible "[!! warn]" line still see the issue.
|
|
2290
2482
|
}
|
|
2291
2483
|
|
|
2292
2484
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
@@ -2534,10 +2726,31 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
2534
2726
|
const tail = buf.trim();
|
|
2535
2727
|
if (tail) handleLine(tail);
|
|
2536
2728
|
if (!handled) {
|
|
2537
|
-
|
|
2729
|
+
// Bug #66 (v0.11.2): stdin closed without an evidence event. Before
|
|
2730
|
+
// declaring an error, try to interpret the raw stdin as a bare
|
|
2731
|
+
// submission object (the common shell-pipe case where `echo
|
|
2732
|
+
// '{...}' | exceptd ai-run secrets` pipes the submission body, not a
|
|
2733
|
+
// wrapped event). If it parses as such, run with it and complete the
|
|
2734
|
+
// phases. Otherwise emit the helpful error.
|
|
2735
|
+
const raw = (process.stdin._consumed || "") || buf;
|
|
2736
|
+
const allText = process.stdin._allText;
|
|
2737
|
+
if (allText && allText.trim()) {
|
|
2738
|
+
try {
|
|
2739
|
+
const parsed = JSON.parse(allText.trim());
|
|
2740
|
+
if (parsed && (parsed.observations || parsed.artifacts || parsed.signal_overrides || parsed.precondition_checks)) {
|
|
2741
|
+
handleLine(JSON.stringify({ event: "evidence", payload: parsed }));
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
} catch { /* fall through to error */ }
|
|
2745
|
+
}
|
|
2746
|
+
writeLine({ event: "error", reason: "stdin closed without an evidence event. Pipe `{\"event\":\"evidence\",\"payload\":{...}}` for streaming mode, or pass --no-stream + --evidence <file> for single-shot." });
|
|
2538
2747
|
process.exit(1);
|
|
2539
2748
|
}
|
|
2540
2749
|
});
|
|
2750
|
+
|
|
2751
|
+
// Capture stdin for the post-close fallback.
|
|
2752
|
+
process.stdin._allText = "";
|
|
2753
|
+
process.stdin.on("data", chunk => { process.stdin._allText += chunk.toString(); });
|
|
2541
2754
|
}
|
|
2542
2755
|
|
|
2543
2756
|
/**
|
|
@@ -2573,6 +2786,31 @@ function buildSubmissionFromPayload(payload) {
|
|
|
2573
2786
|
* phases.direct.threat_context. Returns the top 5 matches with a confidence
|
|
2574
2787
|
* score (matched tokens / total tokens).
|
|
2575
2788
|
*/
|
|
2789
|
+
/**
|
|
2790
|
+
* `ask "<question>"` — plain-English routing to playbook(s).
|
|
2791
|
+
*
|
|
2792
|
+
* v0.11.2 rewrite (#58 / #67): the v0.11.0 implementation only indexed
|
|
2793
|
+
* domain.name + attack_class + first sentence of threat_context, with a
|
|
2794
|
+
* length>3 token filter that dropped short but-meaningful words like "PQC"
|
|
2795
|
+
* or "MCP". The richer index now includes:
|
|
2796
|
+
* - playbook id
|
|
2797
|
+
* - domain.name + domain.attack_class
|
|
2798
|
+
* - domain.attack_refs (T-numbers) + atlas_refs (AML-numbers)
|
|
2799
|
+
* - domain.cwe_refs + frameworks_in_scope
|
|
2800
|
+
* - phases.govern.theater_fingerprints[].claim
|
|
2801
|
+
* - phases.direct.threat_context (full, not first sentence)
|
|
2802
|
+
* - phases.direct.framework_lag_declaration
|
|
2803
|
+
* - skill_chain skill names
|
|
2804
|
+
* - phases.look.collection_scope.asset_scope
|
|
2805
|
+
*
|
|
2806
|
+
* Token filter dropped to length >= 2 (was > 3) so "PQC" / "MCP" / "CI"
|
|
2807
|
+
* tokens match. Synonym map handles common operator phrasings ("API
|
|
2808
|
+
* keys" → secrets, "supply chain" → sbom / library-author, etc).
|
|
2809
|
+
*
|
|
2810
|
+
* Threshold: top match must have score >= 1 (was > 0; same). When no
|
|
2811
|
+
* playbook scores >= 1, fall back to substring match on playbook ID
|
|
2812
|
+
* itself ("secrets" → secrets playbook).
|
|
2813
|
+
*/
|
|
2576
2814
|
function cmdAsk(runner, args, runOpts, pretty) {
|
|
2577
2815
|
const question = (args._ || []).join(" ").trim();
|
|
2578
2816
|
if (!question) {
|
|
@@ -2580,42 +2818,97 @@ function cmdAsk(runner, args, runOpts, pretty) {
|
|
|
2580
2818
|
}
|
|
2581
2819
|
const ids = runner.listPlaybooks();
|
|
2582
2820
|
const q = question.toLowerCase();
|
|
2583
|
-
|
|
2821
|
+
|
|
2822
|
+
// Synonym expansion — common operator phrasings → playbook-relevant tokens.
|
|
2823
|
+
// Keeps cmdAsk dependency-free; rich enough to cover the 80% of natural
|
|
2824
|
+
// queries listed in the operator report.
|
|
2825
|
+
const SYNONYMS = {
|
|
2826
|
+
"credential": ["secret", "key", "token", "password", "cred"],
|
|
2827
|
+
"credentials": ["secret", "key", "token", "password", "cred"],
|
|
2828
|
+
"api key": ["secret", "credential"],
|
|
2829
|
+
"api keys": ["secret", "credential"],
|
|
2830
|
+
"supply chain": ["sbom", "dependency", "vendor", "package", "library", "publish"],
|
|
2831
|
+
"supply-chain": ["sbom", "dependency", "vendor", "package", "library", "publish"],
|
|
2832
|
+
"npm package": ["sbom", "dependency", "library", "publish"],
|
|
2833
|
+
"npm packages": ["sbom", "dependency", "library", "publish"],
|
|
2834
|
+
"pqc": ["post-quantum", "quantum", "crypto", "ml-kem", "ml-dsa", "kyber", "dilithium"],
|
|
2835
|
+
"quantum": ["pqc", "post-quantum"],
|
|
2836
|
+
"audit": ["scan", "review", "check", "validate", "verify"],
|
|
2837
|
+
"mcp": ["model context protocol", "tool", "ai-tool"],
|
|
2838
|
+
"ai": ["llm", "model", "anthropic", "openai", "claude"],
|
|
2839
|
+
"compliance": ["framework", "audit", "soc", "iso", "nist", "gdpr", "dora", "nis2", "regulator"],
|
|
2840
|
+
"kernel": ["lpe", "linux", "privilege", "escalation", "cve", "uname"],
|
|
2841
|
+
"container": ["docker", "kubernetes", "k8s", "compose", "image"],
|
|
2842
|
+
"secret": ["credential", "key", "token", "env", "leak"],
|
|
2843
|
+
"secrets": ["credential", "key", "token", "env", "leak", "repo"],
|
|
2844
|
+
"config": ["configuration", "settings"],
|
|
2845
|
+
};
|
|
2846
|
+
|
|
2847
|
+
// Tokenize question (length >= 2, lowercase) + expand via synonyms.
|
|
2848
|
+
const baseTokens = q.split(/\W+/).filter(t => t.length >= 2);
|
|
2849
|
+
const expanded = new Set(baseTokens);
|
|
2850
|
+
// multi-word synonym keys
|
|
2851
|
+
for (const [phrase, syns] of Object.entries(SYNONYMS)) {
|
|
2852
|
+
if (q.includes(phrase)) for (const s of syns) expanded.add(s);
|
|
2853
|
+
}
|
|
2854
|
+
// single-word synonym keys
|
|
2855
|
+
for (const t of baseTokens) {
|
|
2856
|
+
if (SYNONYMS[t]) for (const s of SYNONYMS[t]) expanded.add(s);
|
|
2857
|
+
}
|
|
2858
|
+
const tokens = [...expanded];
|
|
2859
|
+
|
|
2584
2860
|
const scored = [];
|
|
2585
2861
|
for (const id of ids) {
|
|
2586
2862
|
let pb;
|
|
2587
2863
|
try { pb = runner.loadPlaybook(id); } catch { continue; }
|
|
2588
|
-
const threat = pb.phases?.direct?.threat_context || "";
|
|
2589
|
-
const firstSentence = threat.split(/(?<=[.!?])\s+/)[0] || "";
|
|
2590
2864
|
const haystack = [
|
|
2865
|
+
pb._meta?.id || id,
|
|
2591
2866
|
pb.domain?.name || "",
|
|
2592
2867
|
pb.domain?.attack_class || "",
|
|
2593
|
-
|
|
2868
|
+
...(pb.domain?.attack_refs || []),
|
|
2869
|
+
...(pb.domain?.atlas_refs || []),
|
|
2870
|
+
...(pb.domain?.cwe_refs || []),
|
|
2871
|
+
...(pb.domain?.frameworks_in_scope || []),
|
|
2872
|
+
...((pb.phases?.govern?.theater_fingerprints || []).map(t => t.claim || "")),
|
|
2873
|
+
...((pb.phases?.govern?.theater_fingerprints || []).map(t => t.pattern_id || "")),
|
|
2874
|
+
pb.phases?.direct?.threat_context || "",
|
|
2875
|
+
pb.phases?.direct?.framework_lag_declaration || "",
|
|
2876
|
+
...((pb.phases?.direct?.skill_chain || []).map(s => s.skill || "")),
|
|
2877
|
+
pb.phases?.look?.collection_scope?.asset_scope || "",
|
|
2878
|
+
pb.phases?.look?.collection_scope?.time_window || "",
|
|
2594
2879
|
].join(" ").toLowerCase();
|
|
2595
|
-
|
|
2880
|
+
let score = 0;
|
|
2881
|
+
for (const t of tokens) if (haystack.includes(t)) score++;
|
|
2882
|
+
// ID match counts double — "secrets" should map to the secrets playbook.
|
|
2883
|
+
if (tokens.some(t => (pb._meta?.id || id) === t)) score += 3;
|
|
2596
2884
|
scored.push({ id: pb._meta?.id || id, score });
|
|
2597
2885
|
}
|
|
2598
2886
|
scored.sort((a, b) => b.score - a.score);
|
|
2599
2887
|
const top = scored.filter(s => s.score > 0).slice(0, 5);
|
|
2600
2888
|
|
|
2889
|
+
// v0.11.2: default human-readable; --json for machine.
|
|
2601
2890
|
if (top.length === 0) {
|
|
2602
|
-
|
|
2891
|
+
const result = {
|
|
2603
2892
|
verb: "ask",
|
|
2604
2893
|
question,
|
|
2605
|
-
|
|
2894
|
+
routed_to: [],
|
|
2606
2895
|
hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
|
|
2607
|
-
}
|
|
2896
|
+
};
|
|
2897
|
+
if (args.json) return emit(result, pretty);
|
|
2898
|
+
process.stdout.write(`ask: ${question}\n no playbook matched.\n try: exceptd discover (auto-detect what's in your cwd)\n`);
|
|
2608
2899
|
return;
|
|
2609
2900
|
}
|
|
2610
2901
|
|
|
2611
|
-
|
|
2902
|
+
const result = {
|
|
2612
2903
|
verb: "ask",
|
|
2613
2904
|
question,
|
|
2614
2905
|
routed_to: top.map(t => t.id),
|
|
2615
|
-
confidence: top[0].score / Math.max(
|
|
2906
|
+
confidence: Math.min(1, top[0].score / Math.max(2, tokens.length)),
|
|
2616
2907
|
next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
|
|
2617
2908
|
full_match_list: top,
|
|
2618
|
-
}
|
|
2909
|
+
};
|
|
2910
|
+
if (args.json) return emit(result, pretty);
|
|
2911
|
+
process.stdout.write(`ask: ${question}\n top match: ${top[0].id} (score ${top[0].score})\n next: ${result.next_step}\n alternates: ${top.slice(1).map(t => t.id).join(", ") || "(none)"}\n`);
|
|
2619
2912
|
}
|
|
2620
2913
|
|
|
2621
2914
|
/**
|
|
@@ -2632,11 +2925,29 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
2632
2925
|
const maxRwep = args["max-rwep"] !== undefined ? Number(args["max-rwep"]) : null;
|
|
2633
2926
|
const blockOnClock = !!args["block-on-jurisdiction-clock"];
|
|
2634
2927
|
|
|
2928
|
+
// v0.11.2 bug #63: align with discover. ci now includes cross-cutting
|
|
2929
|
+
// playbooks (framework) automatically, and when --scope code is set on a
|
|
2930
|
+
// cwd that has lockfiles, also includes sbom (which is scope:system but
|
|
2931
|
+
// applies to any repo). Operators expect ci to run "what discover would
|
|
2932
|
+
// recommend"; pre-0.11.2 ci ran scope=X only, which dropped framework +
|
|
2933
|
+
// missed sbom-on-repo.
|
|
2635
2934
|
let ids;
|
|
2636
2935
|
if (args.all) {
|
|
2637
2936
|
ids = runner.listPlaybooks();
|
|
2638
2937
|
} else if (scope) {
|
|
2639
2938
|
ids = filterPlaybooksByScope(runner, scope);
|
|
2939
|
+
// Always include cross-cutting playbooks regardless of scope choice.
|
|
2940
|
+
const cross = filterPlaybooksByScope(runner, "cross-cutting");
|
|
2941
|
+
ids = [...new Set([...ids, ...cross])];
|
|
2942
|
+
// For code-scope on a repo: also include sbom (system-scope but
|
|
2943
|
+
// repo-relevant) so ci output matches discover.
|
|
2944
|
+
if (scope === "code" && fs.existsSync(path.join(process.cwd(), ".git"))) {
|
|
2945
|
+
const hasLockfile = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "requirements.txt", "Pipfile.lock", "Cargo.lock", "go.sum"]
|
|
2946
|
+
.some(f => fs.existsSync(path.join(process.cwd(), f)));
|
|
2947
|
+
if (hasLockfile && runner.listPlaybooks().includes("sbom") && !ids.includes("sbom")) {
|
|
2948
|
+
ids.push("sbom");
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2640
2951
|
} else {
|
|
2641
2952
|
const scopes = detectScopes();
|
|
2642
2953
|
ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
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-12T15:32:44.669Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 49,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "13b2aa2d552d684a06d9865d94e7233438ba95129914b4fd89e15968cf544ff5",
|
|
8
8
|
"data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
|
|
9
9
|
"data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
|
|
10
10
|
"data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
|
package/lib/playbook-runner.js
CHANGED
|
@@ -143,16 +143,69 @@ function preflight(playbook, runOpts = {}) {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// 3. Mutex
|
|
146
|
+
// 3. Mutex — both intra-process (in-memory Set) AND cross-process
|
|
147
|
+
// (filesystem lockfile under .exceptd/locks/<playbook>.lock). v0.11.0 only
|
|
148
|
+
// enforced intra-process; v0.11.1 adds cross-process so two parallel CLI
|
|
149
|
+
// invocations of mutex-conflicting playbooks correctly race-detect.
|
|
147
150
|
for (const conflictId of meta.mutex || []) {
|
|
148
151
|
if (_activeRuns.has(conflictId)) {
|
|
149
|
-
return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict: playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
|
|
152
|
+
return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict (intra-process): playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
|
|
153
|
+
}
|
|
154
|
+
const lockPath = lockFilePath(conflictId);
|
|
155
|
+
if (lockPath && fs.existsSync(lockPath)) {
|
|
156
|
+
// Stale-lock detection: if the recorded PID is dead, ignore the lock.
|
|
157
|
+
try {
|
|
158
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
159
|
+
if (lock.pid && !pidAlive(lock.pid)) {
|
|
160
|
+
fs.unlinkSync(lockPath); // GC stale
|
|
161
|
+
} else {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
blocked_by: 'mutex',
|
|
165
|
+
reason: `Mutex conflict (cross-process): playbook ${conflictId} has an active lock at ${lockPath} (pid ${lock.pid}, started ${lock.started_at}).`,
|
|
166
|
+
issues,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
} catch { /* malformed lockfile — treat as stale and remove */
|
|
170
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
171
|
+
}
|
|
150
172
|
}
|
|
151
173
|
}
|
|
152
174
|
|
|
153
175
|
return { ok: true, issues };
|
|
154
176
|
}
|
|
155
177
|
|
|
178
|
+
function lockDir() {
|
|
179
|
+
const dir = path.join(process.cwd(), '.exceptd', 'locks');
|
|
180
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
181
|
+
return dir;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function lockFilePath(playbookId) {
|
|
185
|
+
try { return path.join(lockDir(), `${playbookId}.lock`); }
|
|
186
|
+
catch { return null; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function acquireLock(playbookId) {
|
|
190
|
+
const p = lockFilePath(playbookId);
|
|
191
|
+
if (!p) return null;
|
|
192
|
+
try {
|
|
193
|
+
fs.writeFileSync(p, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2), { flag: 'wx' });
|
|
194
|
+
return p;
|
|
195
|
+
} catch { return null; /* already locked or unwritable */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function releaseLock(lockPath) {
|
|
199
|
+
if (!lockPath) return;
|
|
200
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pidAlive(pid) {
|
|
204
|
+
if (typeof pid !== 'number') return false;
|
|
205
|
+
try { process.kill(pid, 0); return true; }
|
|
206
|
+
catch (e) { return e.code !== 'ESRCH'; }
|
|
207
|
+
}
|
|
208
|
+
|
|
156
209
|
// --- phase 1: govern ---
|
|
157
210
|
|
|
158
211
|
/**
|
|
@@ -915,6 +968,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
915
968
|
}
|
|
916
969
|
|
|
917
970
|
_activeRuns.add(playbookId);
|
|
971
|
+
// Cross-process mutex lock for this run. preflight verified no other lock
|
|
972
|
+
// exists; we acquire ours and release in the finally block.
|
|
973
|
+
const lockPath = acquireLock(playbookId);
|
|
918
974
|
try {
|
|
919
975
|
const phases = {
|
|
920
976
|
govern: govern(playbookId, directiveId, runOpts),
|
|
@@ -947,6 +1003,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
947
1003
|
};
|
|
948
1004
|
} finally {
|
|
949
1005
|
_activeRuns.delete(playbookId);
|
|
1006
|
+
releaseLock(lockPath);
|
|
950
1007
|
}
|
|
951
1008
|
}
|
|
952
1009
|
|
package/manifest-snapshot.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
|
|
3
|
-
"_generated_at": "2026-05-
|
|
3
|
+
"_generated_at": "2026-05-12T15:31:43.757Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|