@blamejs/exceptd-skills 0.10.3 → 0.11.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/CHANGELOG.md +106 -0
- package/README.md +133 -61
- package/bin/exceptd.js +1621 -146
- package/data/_indexes/_meta.json +2 -2
- package/lib/playbook-runner.js +184 -4
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/index.js +51 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/bin/exceptd.js
CHANGED
|
@@ -100,10 +100,38 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
|
|
|
100
100
|
]);
|
|
101
101
|
|
|
102
102
|
// Seven-phase playbook verbs handled in-process (no subprocess dispatch).
|
|
103
|
+
// v0.11.0 introduces: brief (collapses plan/govern/direct/look), discover (scan + dispatch),
|
|
104
|
+
// doctor (currency + verify + validate-cves + validate-rfcs), ci (CI gate),
|
|
105
|
+
// ai-run (streaming JSONL), ask (plain-English routing).
|
|
103
106
|
const PLAYBOOK_VERBS = new Set([
|
|
104
|
-
|
|
107
|
+
// v0.11.0 canonical surface:
|
|
108
|
+
"brief", "run", "ai-run", "attest", "discover", "doctor", "ci", "ask",
|
|
109
|
+
"verify-attestation", "run-all", "lint",
|
|
110
|
+
// v0.10.x legacy verbs — kept as aliases with deprecation banner, removed in v0.12+:
|
|
111
|
+
"plan", "govern", "direct", "look", "ingest", "reattest", "list-attestations",
|
|
105
112
|
]);
|
|
106
113
|
|
|
114
|
+
// Map legacy verb names to their v0.11.0 replacement so the dispatcher can
|
|
115
|
+
// emit a single deprecation banner per session.
|
|
116
|
+
const LEGACY_VERB_REPLACEMENTS = {
|
|
117
|
+
plan: "brief --all",
|
|
118
|
+
govern: "brief <pb> --phase govern",
|
|
119
|
+
direct: "brief <pb> --phase direct",
|
|
120
|
+
look: "brief <pb> --phase look",
|
|
121
|
+
ingest: "run",
|
|
122
|
+
reattest: "attest diff",
|
|
123
|
+
"list-attestations": "attest list",
|
|
124
|
+
scan: "discover --scan-only",
|
|
125
|
+
dispatch: "discover",
|
|
126
|
+
currency: "doctor --currency",
|
|
127
|
+
verify: "doctor --signatures",
|
|
128
|
+
"validate-cves": "doctor --cves",
|
|
129
|
+
"validate-rfcs": "doctor --rfcs",
|
|
130
|
+
watchlist: "watch",
|
|
131
|
+
prefetch: "refresh --no-network",
|
|
132
|
+
"build-indexes": "refresh --indexes-only",
|
|
133
|
+
};
|
|
134
|
+
|
|
107
135
|
function readPkgVersion() {
|
|
108
136
|
try {
|
|
109
137
|
return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
@@ -112,82 +140,133 @@ function readPkgVersion() {
|
|
|
112
140
|
}
|
|
113
141
|
}
|
|
114
142
|
|
|
143
|
+
function printWelcome() {
|
|
144
|
+
// v0.11.0 redesign #4 — first-run experience. `exceptd` with no args used to
|
|
145
|
+
// print full help (a wall of text). Now it shows two ways in and where to
|
|
146
|
+
// go from there.
|
|
147
|
+
console.log(`exceptd — @blamejs/exceptd-skills v${readPkgVersion()}
|
|
148
|
+
|
|
149
|
+
Welcome. Two ways to start:
|
|
150
|
+
|
|
151
|
+
exceptd discover # scan this directory + recommend playbooks
|
|
152
|
+
exceptd ask "<question>" # plain-English routing to a playbook
|
|
153
|
+
|
|
154
|
+
If you know what you want:
|
|
155
|
+
|
|
156
|
+
exceptd brief <playbook> # what does this playbook check?
|
|
157
|
+
exceptd run <playbook> # run it
|
|
158
|
+
exceptd ci --scope code # CI gate against every code-scoped playbook
|
|
159
|
+
|
|
160
|
+
Common starting playbooks
|
|
161
|
+
code repos: secrets, sbom, library-author, crypto-codebase
|
|
162
|
+
Linux hosts: kernel, hardening, runtime, cred-stores
|
|
163
|
+
AI / service: ai-api, mcp, crypto
|
|
164
|
+
|
|
165
|
+
Full reference: exceptd help
|
|
166
|
+
Per-verb help: exceptd <verb> --help
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
|
|
115
170
|
function printHelp() {
|
|
116
171
|
console.log(`exceptd — @blamejs/exceptd-skills v${readPkgVersion()}
|
|
117
172
|
|
|
118
173
|
Usage: exceptd <command> [args]
|
|
119
174
|
npx @blamejs/exceptd-skills <command> [args]
|
|
120
175
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
v0.11.0 canonical surface
|
|
177
|
+
─────────────────────────
|
|
178
|
+
|
|
179
|
+
brief [playbook] Unified info doc — jurisdictions + threat context
|
|
180
|
+
+ preconditions + artifacts + indicators. Replaces
|
|
181
|
+
plan + govern + direct + look.
|
|
182
|
+
--all every playbook
|
|
183
|
+
--scope <type> system | code | service | cross-cutting
|
|
184
|
+
--directives expand directive metadata
|
|
185
|
+
--phase <name> emit only one phase (legacy compat)
|
|
186
|
+
|
|
187
|
+
run [playbook] Phases 4-7. Auto-detects cwd context when no
|
|
188
|
+
playbook positional.
|
|
189
|
+
--scope <type> | --all | run-all (alias)
|
|
190
|
+
--evidence <file|-> flat or nested submission
|
|
191
|
+
--evidence-dir <dir> per-playbook submission files
|
|
192
|
+
--vex <file> CycloneDX / OpenVEX filter
|
|
193
|
+
--format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary
|
|
194
|
+
--diff-from-latest drift vs prior attestation
|
|
195
|
+
--ci exit-code gate (use \`exceptd ci\` instead)
|
|
196
|
+
--operator <name> bind attestation to identity
|
|
197
|
+
--ack explicit jurisdiction-consent
|
|
198
|
+
--session-id <id> reuse session id (collision refused)
|
|
199
|
+
--force-overwrite override session collision refusal
|
|
200
|
+
--session-key <hex> HMAC sign evidence_package
|
|
201
|
+
--force-stale override threat_currency_score<50 gate
|
|
202
|
+
--air-gap honor air_gap_alternative paths
|
|
203
|
+
|
|
204
|
+
ai-run <playbook> JSONL streaming variant of run. AI emits events
|
|
205
|
+
back on stdin; runner streams phase events on stdout.
|
|
206
|
+
--no-stream single-shot mode
|
|
207
|
+
|
|
208
|
+
attest <subverb> <sid> Auditor-facing operations:
|
|
209
|
+
attest show full attestation
|
|
210
|
+
attest list inventory all sessions
|
|
211
|
+
attest export redacted bundle (--format csaf)
|
|
212
|
+
attest verify Ed25519 signature check
|
|
213
|
+
attest diff drift vs prior or --against <other-sid>
|
|
214
|
+
|
|
215
|
+
discover Scan cwd → recommend playbooks. Replaces scan + dispatch.
|
|
216
|
+
|
|
217
|
+
doctor Health check: signatures + currency + cve catalog
|
|
218
|
+
+ rfc catalog + attestation-signing status.
|
|
219
|
+
--signatures | --currency | --cves | --rfcs
|
|
220
|
+
|
|
221
|
+
ci One-shot CI gate. Exits 2 on detected or rwep≥escalate.
|
|
222
|
+
--all | --scope <type> | (auto-detect)
|
|
223
|
+
--max-rwep <n> cap below playbook default
|
|
224
|
+
--block-on-jurisdiction-clock
|
|
225
|
+
--evidence-dir <dir>
|
|
226
|
+
|
|
227
|
+
ask "<question>" Plain-English routing to playbook(s).
|
|
228
|
+
|
|
229
|
+
lint <pb> <evidence> Pre-flight check submission shape vs playbook
|
|
230
|
+
(preconditions / artifacts / indicators) without
|
|
231
|
+
executing phases 4-7.
|
|
232
|
+
|
|
233
|
+
verify-attestation <sid> Alias for \`attest verify\`.
|
|
234
|
+
run-all Alias for \`run --all\`.
|
|
235
|
+
|
|
142
236
|
skill <name> Show context for a specific skill.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
validate-cves [args] Cross-check CVE catalog vs NVD/KEV/EPSS.
|
|
146
|
-
Add --from-cache to read from prefetch cache.
|
|
147
|
-
validate-rfcs [args] Cross-check RFC catalog vs IETF Datatracker.
|
|
148
|
-
watchlist [args] Forward-watch aggregator across skills.
|
|
149
|
-
|
|
150
|
-
Playbook runner — seven-phase contract
|
|
151
|
-
(govern → direct → look → detect → analyze → validate → close):
|
|
152
|
-
plan [--playbook id]... List playbooks + directives, grouped by scope.
|
|
153
|
-
[--scope system|code|service|cross-cutting|all]
|
|
154
|
-
[--flat] [--mode m] [--session-id id] [--pretty]
|
|
155
|
-
govern <playbook> Phase 1: GRC context (jurisdictions, theater,
|
|
156
|
-
framework gaps, skill_preload).
|
|
157
|
-
[--directive id] [--mode m] [--air-gap]
|
|
158
|
-
direct <playbook> Phase 2: scope (threat_context, rwep_threshold,
|
|
159
|
-
skill_chain, token_budget).
|
|
160
|
-
[--directive id]
|
|
161
|
-
look <playbook> Phase 3: artifact-collection spec the host AI
|
|
162
|
-
should execute.
|
|
163
|
-
[--directive id] [--air-gap]
|
|
164
|
-
run [playbook] Phases 4-7: detect → analyze → validate → close.
|
|
165
|
-
Three invocation modes:
|
|
166
|
-
run <playbook> single playbook (explicit)
|
|
167
|
-
run --scope <type> run all playbooks of that scope
|
|
168
|
-
run --all run every playbook
|
|
169
|
-
run auto-detect from cwd:
|
|
170
|
-
.git/ → code
|
|
171
|
-
/proc + os-release → system
|
|
172
|
-
[--directive id] [--evidence file|-]
|
|
173
|
-
[--session-id id] [--session-key hex]
|
|
174
|
-
[--force-stale] [--air-gap]
|
|
175
|
-
ingest Alias for 'run' matching AGENTS.md terminology.
|
|
176
|
-
[--domain id] [--directive id] [--evidence f|-]
|
|
177
|
-
reattest <session-id> Re-run prior attestation, diff evidence_hash,
|
|
178
|
-
report unchanged | drifted | resolved.
|
|
179
|
-
|
|
180
|
-
Output flags (playbook verbs): default JSON one-line; --pretty for indented.
|
|
181
|
-
|
|
182
|
-
Common:
|
|
183
|
-
help This help.
|
|
237
|
+
framework-gap <fw> <ref> Programmatic gap analysis (one framework, one CVE/scenario).
|
|
238
|
+
path Absolute path to the installed package.
|
|
184
239
|
version Package version.
|
|
185
240
|
|
|
241
|
+
refresh [args] Refresh upstream catalogs + indexes. Replaces
|
|
242
|
+
prefetch + refresh + build-indexes.
|
|
243
|
+
|
|
244
|
+
v0.10.x compatibility (will be removed in v0.12)
|
|
245
|
+
────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
These verbs still work but emit a one-time deprecation banner. Migrate to
|
|
248
|
+
the v0.11.0 verb shown:
|
|
249
|
+
|
|
250
|
+
plan → brief --all govern → brief <pb> --phase govern
|
|
251
|
+
direct → brief <pb> --phase direct look → brief <pb> --phase look
|
|
252
|
+
ingest → run reattest → attest diff
|
|
253
|
+
list-attestations → attest list scan → discover --scan-only
|
|
254
|
+
dispatch → discover currency → doctor --currency
|
|
255
|
+
verify → doctor --signatures validate-cves → doctor --cves
|
|
256
|
+
validate-rfcs → doctor --rfcs watchlist → watch
|
|
257
|
+
prefetch → refresh --no-network build-indexes → refresh --indexes-only
|
|
258
|
+
|
|
259
|
+
Output: default human-readable (v0.11.0). --json for machine output.
|
|
260
|
+
--pretty for indented JSON.
|
|
261
|
+
|
|
186
262
|
Examples:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
263
|
+
exceptd discover # what's in this dir?
|
|
264
|
+
exceptd brief secrets --pretty # what does secrets check?
|
|
265
|
+
exceptd run secrets --evidence ev.json --ci # run + CI gate
|
|
266
|
+
exceptd attest list --playbook secrets # prior attestations
|
|
267
|
+
exceptd attest verify <session-id> # tamper check
|
|
268
|
+
exceptd ci --scope code --max-rwep 70 # gate every code playbook
|
|
269
|
+
exceptd ask "I think someone replaced npm packages" # natural-language route
|
|
191
270
|
|
|
192
271
|
Full documentation: ${PKG_ROOT}/README.md
|
|
193
272
|
Project rules: ${PKG_ROOT}/AGENTS.md
|
|
@@ -196,8 +275,27 @@ Project rules: ${PKG_ROOT}/AGENTS.md
|
|
|
196
275
|
|
|
197
276
|
function main() {
|
|
198
277
|
const argv = process.argv.slice(2);
|
|
278
|
+
|
|
279
|
+
// --json-stdout-only: silence ALL stderr emissions (deprecation banners,
|
|
280
|
+
// unsigned-attestation warnings, hook output). Operators piping the JSON
|
|
281
|
+
// result through `jq` or scripting around exit codes want clean stdout
|
|
282
|
+
// exclusively. Handled here at top of main so the deprecation banner +
|
|
283
|
+
// unsigned warning are suppressed before they fire.
|
|
284
|
+
if (argv.includes("--json-stdout-only")) {
|
|
285
|
+
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
286
|
+
process.env.EXCEPTD_UNSIGNED_WARNED = "1";
|
|
287
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
288
|
+
process.stderr.write = (chunk, encoding, cb) => {
|
|
289
|
+
// Let actual error frames through (uncaught exceptions need to surface
|
|
290
|
+
// for debugging); suppress framework stderr.
|
|
291
|
+
if (typeof chunk === "string" && chunk.startsWith("Error")) return origStderrWrite(chunk, encoding, cb);
|
|
292
|
+
if (typeof cb === "function") cb();
|
|
293
|
+
return true;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
199
297
|
if (argv.length === 0) {
|
|
200
|
-
|
|
298
|
+
printWelcome();
|
|
201
299
|
process.exit(0);
|
|
202
300
|
}
|
|
203
301
|
const cmd = argv[0];
|
|
@@ -219,6 +317,24 @@ function main() {
|
|
|
219
317
|
// Seven-phase playbook verbs run in-process — they emit JSON to stdout
|
|
220
318
|
// rather than dispatch to a script.
|
|
221
319
|
if (PLAYBOOK_VERBS.has(cmd)) {
|
|
320
|
+
// One-time deprecation banner per process when a legacy verb is invoked.
|
|
321
|
+
if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
|
|
322
|
+
// Mention the installed version explicitly so an operator on v0.10.x
|
|
323
|
+
// who reads "Prefer brief..." doesn't go looking for a verb that
|
|
324
|
+
// doesn't exist in their install. v0.11.0+ has the replacement; v0.10.x
|
|
325
|
+
// users see this with the explicit "upgrade to v0.11.0 first" note.
|
|
326
|
+
const ver = readPkgVersion();
|
|
327
|
+
const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
|
|
328
|
+
process.stderr.write(
|
|
329
|
+
`[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
|
|
330
|
+
(haveBrief
|
|
331
|
+
? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
|
|
332
|
+
: `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
|
|
333
|
+
`Legacy verbs remain functional through this release; they will be removed in v0.12. ` +
|
|
334
|
+
`Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
|
|
335
|
+
);
|
|
336
|
+
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
337
|
+
}
|
|
222
338
|
dispatchPlaybook(cmd, rest);
|
|
223
339
|
return;
|
|
224
340
|
}
|
|
@@ -340,7 +456,9 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
340
456
|
|
|
341
457
|
const args = parseArgs(argv, {
|
|
342
458
|
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
343
|
-
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack"
|
|
459
|
+
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
460
|
+
"force-overwrite", "no-stream", "block-on-jurisdiction-clock",
|
|
461
|
+
"json-stdout-only"],
|
|
344
462
|
multi: ["playbook", "format"],
|
|
345
463
|
});
|
|
346
464
|
const pretty = !!args.pretty;
|
|
@@ -349,8 +467,28 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
349
467
|
forceStale: !!args["force-stale"],
|
|
350
468
|
};
|
|
351
469
|
if (args["session-id"]) runOpts.session_id = args["session-id"];
|
|
352
|
-
if (args["
|
|
353
|
-
if (args
|
|
470
|
+
if (args["attestation-root"]) runOpts.attestationRoot = args["attestation-root"];
|
|
471
|
+
if (args["session-key"]) {
|
|
472
|
+
// Bug #33: validate that --session-key is hex. Previously any string was
|
|
473
|
+
// silently accepted; HMAC signing then either failed silently or produced
|
|
474
|
+
// an unverifiable signature.
|
|
475
|
+
if (!/^[0-9a-fA-F]+$/.test(args["session-key"])) {
|
|
476
|
+
return emitError("run: --session-key must be hex characters only (0-9, a-f). Generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", { provided_length: args["session-key"].length }, pretty);
|
|
477
|
+
}
|
|
478
|
+
if (args["session-key"].length < 16) {
|
|
479
|
+
return emitError("run: --session-key is too short (need at least 16 hex chars / 64 bits of entropy).", { provided_length: args["session-key"].length }, pretty);
|
|
480
|
+
}
|
|
481
|
+
runOpts.session_key = args["session-key"];
|
|
482
|
+
}
|
|
483
|
+
if (args.mode) {
|
|
484
|
+
// Bug #32: validate --mode against the accepted set. Previously
|
|
485
|
+
// `--mode garbage` was silently accepted.
|
|
486
|
+
const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
|
|
487
|
+
if (!VALID_MODES.includes(args.mode)) {
|
|
488
|
+
return emitError(`run: --mode "${args.mode}" not in accepted set ${JSON.stringify(VALID_MODES)}.`, { provided: args.mode }, pretty);
|
|
489
|
+
}
|
|
490
|
+
runOpts.mode = args.mode;
|
|
491
|
+
}
|
|
354
492
|
// Multi-operator teams need attestations bound to a specific human or
|
|
355
493
|
// service identity. --operator <name> persists into the attestation file
|
|
356
494
|
// for audit-trail accountability. Free-form string; no validation.
|
|
@@ -380,6 +518,15 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
380
518
|
case "reattest": return cmdReattest(runner, args, runOpts, pretty);
|
|
381
519
|
case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
|
|
382
520
|
case "attest": return cmdAttest(runner, args, runOpts, pretty);
|
|
521
|
+
case "brief": return cmdBrief(runner, args, runOpts, pretty);
|
|
522
|
+
case "run-all": return cmdRunAll(runner, args, runOpts, pretty);
|
|
523
|
+
case "verify-attestation": return cmdVerifyAttestation(runner, args, runOpts, pretty);
|
|
524
|
+
case "lint": return cmdLint(runner, args, runOpts, pretty);
|
|
525
|
+
case "discover": return cmdDiscover(runner, args, runOpts, pretty);
|
|
526
|
+
case "doctor": return cmdDoctor(runner, args, runOpts, pretty);
|
|
527
|
+
case "ai-run": return cmdAiRun(runner, args, runOpts, pretty);
|
|
528
|
+
case "ask": return cmdAsk(runner, args, runOpts, pretty);
|
|
529
|
+
case "ci": return cmdCi(runner, args, runOpts, pretty);
|
|
383
530
|
}
|
|
384
531
|
} catch (e) {
|
|
385
532
|
emitError(e.message, { verb: cmd }, pretty);
|
|
@@ -517,10 +664,261 @@ Subverbs:
|
|
|
517
664
|
Reports tamper status per attestation file.
|
|
518
665
|
|
|
519
666
|
All subverbs honor --pretty for indented JSON output.`,
|
|
667
|
+
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
668
|
+
|
|
669
|
+
Replaces: scan + dispatch + recommend.
|
|
670
|
+
|
|
671
|
+
Sniffs the cwd (.git/, package.json, pyproject.toml, requirements.txt,
|
|
672
|
+
Cargo.toml, go.mod, Dockerfile, docker-compose.yml, *.tf, k8s/, .env) and
|
|
673
|
+
on Linux reads /etc/os-release to detect host distro. Emits a list of
|
|
674
|
+
recommended exceptd playbooks tailored to what was found.
|
|
675
|
+
|
|
676
|
+
Flags:
|
|
677
|
+
--scan-only Also include legacy \`scan\` output under legacy_scan.
|
|
678
|
+
--json Emit JSON (default is human-readable text).
|
|
679
|
+
--pretty Indented JSON output (implies --json).
|
|
680
|
+
|
|
681
|
+
Output: context + recommended_playbooks[] + next_steps[].`,
|
|
682
|
+
doctor: `doctor — one-shot health check (v0.11.0).
|
|
683
|
+
|
|
684
|
+
Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
|
|
685
|
+
|
|
686
|
+
Subchecks:
|
|
687
|
+
--signatures Ed25519 signature verification across all skills.
|
|
688
|
+
--currency Skill currency report (last_threat_review).
|
|
689
|
+
--cves CVE catalog validation (offline view).
|
|
690
|
+
--rfcs RFC catalog validation (offline view).
|
|
691
|
+
(no flag) All four, plus signing-status (private key presence).
|
|
692
|
+
|
|
693
|
+
Flags:
|
|
694
|
+
--json Emit JSON (default is human-readable text).
|
|
695
|
+
--pretty Indented JSON output (implies --json).
|
|
696
|
+
|
|
697
|
+
Output: checks{} per subcheck + summary{all_green, issues_count}.`,
|
|
698
|
+
"ai-run": `ai-run <playbook> — streaming JSONL contract for AI-driven runs (v0.11.0).
|
|
699
|
+
|
|
700
|
+
Emits one JSON event per line as the seven phases progress, and reads
|
|
701
|
+
evidence events back on stdin. Single pipe instead of brief → look → run.
|
|
702
|
+
|
|
703
|
+
Flags:
|
|
704
|
+
<playbook> Required positional.
|
|
705
|
+
--directive <id> Specific directive (default: first one).
|
|
706
|
+
--no-stream Single-shot mode: emit all phases as one JSON doc
|
|
707
|
+
without reading stdin (uses runner.run directly).
|
|
708
|
+
--pretty Indented JSON output (single-shot only).
|
|
709
|
+
|
|
710
|
+
Stdin event grammar (one JSON object per line):
|
|
711
|
+
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
712
|
+
|
|
713
|
+
Emits phases: govern → direct → look → await_evidence → detect → analyze
|
|
714
|
+
→ validate → close, then {"event":"done","ok":true,"session_id":"..."}.
|
|
715
|
+
Errors emit {"event":"error","reason":"..."} and exit non-zero.`,
|
|
716
|
+
ask: `ask "<plain-English question>" — keyword routing to playbooks (v0.11.0).
|
|
717
|
+
|
|
718
|
+
Tokenises the question (words > 3 chars), scores every playbook by overlap
|
|
719
|
+
against domain.name + domain.attack_class + the first sentence of
|
|
720
|
+
phases.direct.threat_context, returns the top 5 matches with a confidence
|
|
721
|
+
score.
|
|
722
|
+
|
|
723
|
+
Args / flags:
|
|
724
|
+
"<question>" Plain-English question. Wrap in quotes.
|
|
725
|
+
--pretty Indented JSON output.
|
|
726
|
+
|
|
727
|
+
Output: { verb, question, routed_to:[ids], confidence, next_step,
|
|
728
|
+
full_match_list }. Empty match list when no token overlap — surfaces a
|
|
729
|
+
hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.`,
|
|
730
|
+
ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
|
|
731
|
+
|
|
732
|
+
Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
|
|
733
|
+
exit-code contract designed for one-line .github/workflows entries.
|
|
734
|
+
|
|
735
|
+
Flags:
|
|
736
|
+
--all Run every playbook.
|
|
737
|
+
--scope <type> Filter: system | code | service | cross-cutting.
|
|
738
|
+
(no flag) Auto-detect scopes from cwd (same logic as run).
|
|
739
|
+
--evidence <file> Submission bundle (multi-playbook shape).
|
|
740
|
+
--evidence-dir <dir> Read <playbook-id>.json files from a directory.
|
|
741
|
+
--max-rwep <int> Override RWEP escalate threshold (default: per-playbook).
|
|
742
|
+
--block-on-jurisdiction-clock
|
|
743
|
+
Fail when any close.notification_actions started a
|
|
744
|
+
regulatory clock (GDPR 72h, HIPAA breach, etc.).
|
|
745
|
+
--pretty Indented JSON output.
|
|
746
|
+
|
|
747
|
+
Exit codes: 0 PASS, 2 FAIL (detected | rwep ≥ cap | clock started w/ block flag).
|
|
748
|
+
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
749
|
+
max_rwep_observed, jurisdiction_clocks_started, verdict}, results[].`,
|
|
520
750
|
};
|
|
521
751
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
522
752
|
}
|
|
523
753
|
|
|
754
|
+
/**
|
|
755
|
+
* `brief` — collapses plan + govern + direct + look into one informational
|
|
756
|
+
* document. Phases 1-3 of the seven-phase contract are entirely informational
|
|
757
|
+
* (no state mutation), so the AI reads ONE document instead of three CLI
|
|
758
|
+
* round-trips.
|
|
759
|
+
*
|
|
760
|
+
* Modes:
|
|
761
|
+
* brief <playbook> → one playbook, all three info phases unified
|
|
762
|
+
* brief --all → every playbook (replaces `plan`)
|
|
763
|
+
* brief <playbook> --phase <name>
|
|
764
|
+
* → emit only the named phase (compat with
|
|
765
|
+
* legacy `govern`/`direct`/`look` callers)
|
|
766
|
+
*/
|
|
767
|
+
/**
|
|
768
|
+
* `lint <playbook> <evidence-file>` — pre-flight check the submission shape
|
|
769
|
+
* against the playbook's expected indicators / preconditions / artifacts
|
|
770
|
+
* WITHOUT executing detect/analyze/validate/close. Lets the AI iterate on
|
|
771
|
+
* its evidence JSON before going through phases 4-7. Returns a categorized
|
|
772
|
+
* list: ok / missing_required / unknown_keys / type_mismatch / suggestions.
|
|
773
|
+
*/
|
|
774
|
+
function cmdLint(runner, args, runOpts, pretty) {
|
|
775
|
+
const playbookId = args._[0];
|
|
776
|
+
const evidencePath = args._[1] || args.evidence;
|
|
777
|
+
if (!playbookId || !evidencePath) {
|
|
778
|
+
return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
|
|
779
|
+
}
|
|
780
|
+
let pb;
|
|
781
|
+
try { pb = runner.loadPlaybook(playbookId); }
|
|
782
|
+
catch (e) { return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty); }
|
|
783
|
+
|
|
784
|
+
let submission;
|
|
785
|
+
try { submission = readEvidence(evidencePath); }
|
|
786
|
+
catch (e) { return emitError(`lint: failed to read evidence: ${e.message}`, { evidence: evidencePath }, pretty); }
|
|
787
|
+
|
|
788
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
789
|
+
const resolved = runner._resolvedPhase;
|
|
790
|
+
const lookPhase = pb.phases?.look || {};
|
|
791
|
+
const detectPhase = pb.phases?.detect || {};
|
|
792
|
+
|
|
793
|
+
const requiredArtifacts = (lookPhase.artifacts || []).filter(a => a.required).map(a => a.id);
|
|
794
|
+
const knownArtifacts = new Set((lookPhase.artifacts || []).map(a => a.id));
|
|
795
|
+
const knownIndicators = new Set((detectPhase.indicators || []).map(i => i.id));
|
|
796
|
+
const knownPreconditions = new Set((pb._meta?.preconditions || []).map(p => p.id));
|
|
797
|
+
|
|
798
|
+
// Support both flat (observations) and nested (artifacts/signal_overrides) shapes.
|
|
799
|
+
const flat = submission.observations || null;
|
|
800
|
+
const artifactsKey = flat ? flat : (submission.artifacts || {});
|
|
801
|
+
const signalsKey = flat ? flat : (submission.signal_overrides || {});
|
|
802
|
+
|
|
803
|
+
const missingRequired = requiredArtifacts.filter(id => {
|
|
804
|
+
const a = artifactsKey[id];
|
|
805
|
+
return !a || (flat ? !a.captured : !a.captured);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const unknownArtifactKeys = Object.keys(submission.artifacts || {})
|
|
809
|
+
.filter(k => !knownArtifacts.has(k));
|
|
810
|
+
const unknownSignalKeys = Object.keys(submission.signal_overrides || {})
|
|
811
|
+
.filter(k => !knownIndicators.has(k));
|
|
812
|
+
const unknownObservationKeys = flat
|
|
813
|
+
? Object.keys(flat).filter(k => !knownArtifacts.has(k) && !knownIndicators.has(k) && !knownPreconditions.has(k))
|
|
814
|
+
: [];
|
|
815
|
+
|
|
816
|
+
const unsuppliedPreconditions = [...knownPreconditions].filter(
|
|
817
|
+
p => !((submission.precondition_checks || {}).hasOwnProperty(p) || (flat || {}).hasOwnProperty(p))
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
const issues = [];
|
|
821
|
+
for (const id of missingRequired) {
|
|
822
|
+
issues.push({ severity: "error", kind: "missing_required_artifact", artifact_id: id, hint: `Add to submission.artifacts.${id} = { value, captured: true } (or under observations in the flat shape).` });
|
|
823
|
+
}
|
|
824
|
+
for (const k of unknownArtifactKeys) {
|
|
825
|
+
issues.push({ severity: "warn", kind: "unknown_artifact_key", key: k, hint: `Not in playbook ${playbookId} look.artifacts[]. Recognized: ${[...knownArtifacts].slice(0, 10).join(", ")}…` });
|
|
826
|
+
}
|
|
827
|
+
for (const k of unknownSignalKeys) {
|
|
828
|
+
issues.push({ severity: "warn", kind: "unknown_signal_override_key", key: k, hint: `Not in playbook ${playbookId} detect.indicators[]. Run \`exceptd run ${playbookId} --signal-list\` to enumerate.` });
|
|
829
|
+
}
|
|
830
|
+
for (const p of unsuppliedPreconditions) {
|
|
831
|
+
issues.push({ severity: "info", kind: "precondition_unverified", precondition_id: p, hint: `Add submission.precondition_checks.${p} = true|false (or under observations in the flat shape).` });
|
|
832
|
+
}
|
|
833
|
+
for (const k of unknownObservationKeys) {
|
|
834
|
+
issues.push({ severity: "warn", kind: "unknown_observation_key", key: k });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const ok = issues.every(i => i.severity !== "error");
|
|
838
|
+
emit({
|
|
839
|
+
verb: "lint",
|
|
840
|
+
ok,
|
|
841
|
+
playbook_id: playbookId,
|
|
842
|
+
directive_id: directiveId,
|
|
843
|
+
submission_shape: flat ? "flat (v0.11.0)" : "nested (v0.10.x)",
|
|
844
|
+
summary: {
|
|
845
|
+
errors: issues.filter(i => i.severity === "error").length,
|
|
846
|
+
warnings: issues.filter(i => i.severity === "warn").length,
|
|
847
|
+
info: issues.filter(i => i.severity === "info").length,
|
|
848
|
+
},
|
|
849
|
+
issues,
|
|
850
|
+
}, pretty);
|
|
851
|
+
if (!ok) process.exitCode = 1;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function cmdBrief(runner, args, runOpts, pretty) {
|
|
855
|
+
const playbookId = args._[0];
|
|
856
|
+
const onlyPhase = args.phase || null;
|
|
857
|
+
|
|
858
|
+
if (!playbookId || args.all) {
|
|
859
|
+
// Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
|
|
860
|
+
return cmdPlan(runner, args, runOpts, pretty);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const pb = runner.loadPlaybook(playbookId);
|
|
864
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
865
|
+
|
|
866
|
+
const govern = runner.govern(playbookId, directiveId, runOpts);
|
|
867
|
+
const direct = runner.direct(playbookId, directiveId);
|
|
868
|
+
const look = runner.look(playbookId, directiveId, runOpts);
|
|
869
|
+
|
|
870
|
+
// If --phase was passed, emit only that phase to ease legacy migration.
|
|
871
|
+
if (onlyPhase === "govern") return emit(govern, pretty);
|
|
872
|
+
if (onlyPhase === "direct") return emit(direct, pretty);
|
|
873
|
+
if (onlyPhase === "look") return emit(look, pretty);
|
|
874
|
+
|
|
875
|
+
emit({
|
|
876
|
+
verb: "brief",
|
|
877
|
+
playbook_id: playbookId,
|
|
878
|
+
directive_id: directiveId,
|
|
879
|
+
scope: pb._meta?.scope || null,
|
|
880
|
+
threat_currency_score: pb._meta?.threat_currency_score,
|
|
881
|
+
|
|
882
|
+
// From govern phase:
|
|
883
|
+
jurisdiction_obligations: govern.jurisdiction_obligations,
|
|
884
|
+
theater_fingerprints: govern.theater_fingerprints,
|
|
885
|
+
framework_context: govern.framework_context,
|
|
886
|
+
skill_preload: govern.skill_preload,
|
|
887
|
+
|
|
888
|
+
// From direct phase:
|
|
889
|
+
threat_context: direct.threat_context,
|
|
890
|
+
rwep_threshold: direct.rwep_threshold,
|
|
891
|
+
framework_lag_declaration: direct.framework_lag_declaration,
|
|
892
|
+
skill_chain: direct.skill_chain,
|
|
893
|
+
token_budget: direct.token_budget,
|
|
894
|
+
|
|
895
|
+
// From look phase:
|
|
896
|
+
preconditions: look.preconditions,
|
|
897
|
+
precondition_submission_shape: look.precondition_submission_shape,
|
|
898
|
+
artifacts: look.artifacts,
|
|
899
|
+
collection_scope: look.collection_scope,
|
|
900
|
+
environment_assumptions: look.environment_assumptions,
|
|
901
|
+
fallback_if_unavailable: look.fallback_if_unavailable,
|
|
902
|
+
|
|
903
|
+
// Forward references — what the AI will see during run:
|
|
904
|
+
detect_indicators_preview: (pb.phases?.detect?.indicators || []).map(i => ({
|
|
905
|
+
id: i.id, type: i.type, confidence: i.confidence, deterministic: !!i.deterministic
|
|
906
|
+
})),
|
|
907
|
+
}, pretty);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** `run-all` alias for `run --all`. */
|
|
911
|
+
function cmdRunAll(runner, args, runOpts, pretty) {
|
|
912
|
+
args.all = true;
|
|
913
|
+
return cmdRun(runner, args, runOpts, pretty);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** `verify-attestation <sid>` alias for `attest verify <sid>`. */
|
|
917
|
+
function cmdVerifyAttestation(runner, args, runOpts, pretty) {
|
|
918
|
+
args._ = ["verify", ...(args._ || [])];
|
|
919
|
+
return cmdAttest(runner, args, runOpts, pretty);
|
|
920
|
+
}
|
|
921
|
+
|
|
524
922
|
function cmdPlan(runner, args, runOpts, pretty) {
|
|
525
923
|
let playbookIds = args.playbook
|
|
526
924
|
? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
|
|
@@ -711,6 +1109,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
711
1109
|
}
|
|
712
1110
|
|
|
713
1111
|
let submission = {};
|
|
1112
|
+
// v0.11.1: auto-detect piped stdin (process.stdin.isTTY === false means
|
|
1113
|
+
// something is piping into us). If no --evidence flag and stdin is a pipe,
|
|
1114
|
+
// assume `--evidence -`. Operators forgetting the flag previously got a
|
|
1115
|
+
// confusing precondition halt; now the common case "just works."
|
|
1116
|
+
if (!args.evidence && process.stdin.isTTY === false) {
|
|
1117
|
+
args.evidence = "-";
|
|
1118
|
+
}
|
|
714
1119
|
if (args.evidence) {
|
|
715
1120
|
try {
|
|
716
1121
|
submission = readEvidence(args.evidence);
|
|
@@ -751,27 +1156,41 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
751
1156
|
|
|
752
1157
|
// Persist attestation for reattest cycles when the run succeeded.
|
|
753
1158
|
if (result && result.ok && result.session_id) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1159
|
+
const persistResult = persistAttestation({
|
|
1160
|
+
sessionId: result.session_id,
|
|
1161
|
+
playbookId: result.playbook_id,
|
|
1162
|
+
directiveId: result.directive_id,
|
|
1163
|
+
evidenceHash: result.evidence_hash,
|
|
1164
|
+
operator: runOpts.operator,
|
|
1165
|
+
operatorConsent: runOpts.operator_consent,
|
|
1166
|
+
submission,
|
|
1167
|
+
runOpts,
|
|
1168
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
1169
|
+
filename: "attestation.json",
|
|
1170
|
+
});
|
|
1171
|
+
if (!persistResult.ok) {
|
|
1172
|
+
// Session-id collision without --force-overwrite. Refuse, surface the
|
|
1173
|
+
// existing path so the operator can decide, and emit JSON to stderr
|
|
1174
|
+
// matching the unified error shape. Exit non-zero — a silent overwrite
|
|
1175
|
+
// is a tamper-evidence violation.
|
|
1176
|
+
const err = {
|
|
1177
|
+
ok: false,
|
|
1178
|
+
error: persistResult.error,
|
|
1179
|
+
existing_attestation: persistResult.existingPath,
|
|
1180
|
+
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
1181
|
+
verb: "run",
|
|
767
1182
|
};
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
1183
|
+
process.stderr.write(JSON.stringify(err) + "\n");
|
|
1184
|
+
process.exit(3);
|
|
1185
|
+
}
|
|
1186
|
+
if (persistResult.prior_session_id) {
|
|
1187
|
+
// Force-overwrite happened — surface the prior_session_id in the
|
|
1188
|
+
// returned result so the operator/AI can see what the new attestation
|
|
1189
|
+
// replaced and link back via the prior_session_id field persisted on
|
|
1190
|
+
// disk.
|
|
1191
|
+
result.prior_session_id = persistResult.prior_session_id;
|
|
1192
|
+
result.overwrote_at = persistResult.overwrote_at;
|
|
1193
|
+
}
|
|
775
1194
|
}
|
|
776
1195
|
|
|
777
1196
|
if (result && result.ok === false) {
|
|
@@ -899,23 +1318,26 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
899
1318
|
|
|
900
1319
|
// Persist per-playbook attestation under the shared session.
|
|
901
1320
|
if (result && result.ok) {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1321
|
+
const persisted = persistAttestation({
|
|
1322
|
+
sessionId,
|
|
1323
|
+
playbookId: id,
|
|
1324
|
+
directiveId,
|
|
1325
|
+
evidenceHash: result.evidence_hash,
|
|
1326
|
+
operator: perRunOpts.operator,
|
|
1327
|
+
operatorConsent: perRunOpts.operator_consent,
|
|
1328
|
+
submission,
|
|
1329
|
+
runOpts: perRunOpts,
|
|
1330
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
1331
|
+
filename: `${id}.json`,
|
|
1332
|
+
});
|
|
1333
|
+
if (!persisted.ok) {
|
|
1334
|
+
// Multi-run collision: don't abort the whole bundle; surface in the
|
|
1335
|
+
// per-playbook result so the operator can see exactly which
|
|
1336
|
+
// playbook's attestation refused to overwrite.
|
|
1337
|
+
result.attestation_persist = { ok: false, error: persisted.error };
|
|
1338
|
+
} else if (persisted.prior_session_id) {
|
|
1339
|
+
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
1340
|
+
}
|
|
919
1341
|
}
|
|
920
1342
|
results.push(result);
|
|
921
1343
|
}
|
|
@@ -971,7 +1393,7 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
971
1393
|
|
|
972
1394
|
if (result && result.ok && result.session_id) {
|
|
973
1395
|
try {
|
|
974
|
-
const dir = path.join(
|
|
1396
|
+
const dir = path.join(resolveAttestationRoot(runOpts), result.session_id);
|
|
975
1397
|
fs.mkdirSync(dir, { recursive: true });
|
|
976
1398
|
fs.writeFileSync(
|
|
977
1399
|
path.join(dir, "attestation.json"),
|
|
@@ -995,6 +1417,113 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
995
1417
|
emit(result, pretty);
|
|
996
1418
|
}
|
|
997
1419
|
|
|
1420
|
+
/**
|
|
1421
|
+
* Resolve the attestation root for a given run. Resolution order (most-specific
|
|
1422
|
+
* first):
|
|
1423
|
+
* 1. --attestation-root <path> explicit caller override
|
|
1424
|
+
* 2. EXCEPTD_HOME env var operator-level configuration
|
|
1425
|
+
* 3. ~/.exceptd/attestations/<repo-or-host-tag>/ default (v0.11.0+)
|
|
1426
|
+
* 4. .exceptd/attestations/ legacy cwd-relative fallback when ~/.exceptd
|
|
1427
|
+
* can't be created (read-only home / sandbox)
|
|
1428
|
+
*
|
|
1429
|
+
* Repo tag is derived from `git config --get remote.origin.url` + branch when
|
|
1430
|
+
* available, else a hostname tag. This means `attest list` works regardless of
|
|
1431
|
+
* which directory you happened to run from. Operators can override via env.
|
|
1432
|
+
*/
|
|
1433
|
+
function resolveAttestationRoot(runOpts) {
|
|
1434
|
+
if (runOpts && runOpts.attestationRoot) return runOpts.attestationRoot;
|
|
1435
|
+
if (process.env.EXCEPTD_HOME) return path.join(process.env.EXCEPTD_HOME, "attestations");
|
|
1436
|
+
const home = require("os").homedir();
|
|
1437
|
+
if (!home) return path.join(process.cwd(), ".exceptd", "attestations");
|
|
1438
|
+
const root = path.join(home, ".exceptd", "attestations", deriveRunTag());
|
|
1439
|
+
try {
|
|
1440
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1441
|
+
return root;
|
|
1442
|
+
} catch {
|
|
1443
|
+
return path.join(process.cwd(), ".exceptd", "attestations");
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Derive a stable tag for attestations: `<repo-name>@<branch>` when in a git
|
|
1449
|
+
* repo, else `host:<hostname>`. Used as the per-context directory under
|
|
1450
|
+
* ~/.exceptd/attestations/ so multi-repo operators don't conflate sessions.
|
|
1451
|
+
*/
|
|
1452
|
+
function deriveRunTag() {
|
|
1453
|
+
const { spawnSync } = require("child_process");
|
|
1454
|
+
try {
|
|
1455
|
+
const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" });
|
|
1456
|
+
if (remote.status === 0 && remote.stdout.trim()) {
|
|
1457
|
+
const url = remote.stdout.trim();
|
|
1458
|
+
const repoName = (url.match(/[\/:]([^/]+?)(?:\.git)?$/) || [, "unknown"])[1];
|
|
1459
|
+
const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
1460
|
+
const branchName = branch.status === 0 ? branch.stdout.trim() : "head";
|
|
1461
|
+
return `${repoName}@${branchName}`.replace(/[^A-Za-z0-9._@-]/g, "_");
|
|
1462
|
+
}
|
|
1463
|
+
} catch {}
|
|
1464
|
+
return `host:${require("os").hostname()}`.replace(/[^A-Za-z0-9._@:-]/g, "_");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Persist an attestation file. Refuses to overwrite an existing file unless
|
|
1469
|
+
* `forceOverwrite` is true. When force-overwriting, the new attestation
|
|
1470
|
+
* records `prior_session_id` (== current session_id; the prior content is
|
|
1471
|
+
* what's being replaced) plus a `prior_evidence_hash` link extracted from
|
|
1472
|
+
* the file on disk before clobbering — so the audit-trail chain survives.
|
|
1473
|
+
*
|
|
1474
|
+
* Returns { ok: true, prior_session_id?, overwrote_at?, persist_path } on
|
|
1475
|
+
* success; or { ok: false, error, existingPath } when the operator hit a
|
|
1476
|
+
* collision without --force-overwrite.
|
|
1477
|
+
*/
|
|
1478
|
+
function persistAttestation(args) {
|
|
1479
|
+
const { sessionId, playbookId, directiveId, evidenceHash, operator,
|
|
1480
|
+
operatorConsent, submission, runOpts, forceOverwrite, filename } = args;
|
|
1481
|
+
const root = resolveAttestationRoot(runOpts);
|
|
1482
|
+
const dir = path.join(root, sessionId);
|
|
1483
|
+
const filePath = path.join(dir, filename);
|
|
1484
|
+
|
|
1485
|
+
let prior = null;
|
|
1486
|
+
if (fs.existsSync(filePath)) {
|
|
1487
|
+
try { prior = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
|
|
1488
|
+
if (!forceOverwrite) {
|
|
1489
|
+
return {
|
|
1490
|
+
ok: false,
|
|
1491
|
+
error: `Attestation already exists at ${path.relative(process.cwd(), filePath)}. Session-id collision (${sessionId}) — refusing to overwrite to preserve audit trail.`,
|
|
1492
|
+
existingPath: path.relative(process.cwd(), filePath),
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
try {
|
|
1498
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1499
|
+
const attestation = {
|
|
1500
|
+
session_id: sessionId,
|
|
1501
|
+
playbook_id: playbookId,
|
|
1502
|
+
directive_id: directiveId,
|
|
1503
|
+
evidence_hash: evidenceHash,
|
|
1504
|
+
operator: operator || null,
|
|
1505
|
+
operator_consent: operatorConsent || null,
|
|
1506
|
+
submission,
|
|
1507
|
+
run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
|
|
1508
|
+
captured_at: new Date().toISOString(),
|
|
1509
|
+
// When overwriting (with --force-overwrite), link to the prior content
|
|
1510
|
+
// by evidence_hash + capture timestamp. session_id is the same (that's
|
|
1511
|
+
// why we collided), so it's the hash + timestamp that distinguish.
|
|
1512
|
+
prior_evidence_hash: prior ? (prior.evidence_hash || null) : null,
|
|
1513
|
+
prior_captured_at: prior ? (prior.captured_at || null) : null,
|
|
1514
|
+
};
|
|
1515
|
+
fs.writeFileSync(filePath, JSON.stringify(attestation, null, 2));
|
|
1516
|
+
maybeSignAttestation(filePath);
|
|
1517
|
+
return {
|
|
1518
|
+
ok: true,
|
|
1519
|
+
prior_session_id: prior ? sessionId : null,
|
|
1520
|
+
overwrote_at: prior ? prior.captured_at : null,
|
|
1521
|
+
};
|
|
1522
|
+
} catch (e) {
|
|
1523
|
+
return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
998
1527
|
/**
|
|
999
1528
|
* Ed25519-sign an attestation file when .keys/private.pem is available
|
|
1000
1529
|
* (matches lib/sign.js convention for skill signing). Writes a sidecar
|
|
@@ -1010,6 +1539,19 @@ function maybeSignAttestation(filePath) {
|
|
|
1010
1539
|
const sigPath = filePath + ".sig";
|
|
1011
1540
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
1012
1541
|
const content = fs.readFileSync(filePath, "utf8");
|
|
1542
|
+
// One-time-per-process unsigned warning so cron jobs don't spam stderr.
|
|
1543
|
+
// Operators who set `.keys/private.pem` get tamper-evident attestations;
|
|
1544
|
+
// operators without the keypair get a single nudge per session telling them
|
|
1545
|
+
// exactly how to enable signing.
|
|
1546
|
+
if (!fs.existsSync(privKeyPath) && !process.env.EXCEPTD_UNSIGNED_WARNED) {
|
|
1547
|
+
process.stderr.write(
|
|
1548
|
+
"[attest] attestation will be written UNSIGNED (no private key at .keys/private.pem). " +
|
|
1549
|
+
"Operators reading the attestation later can verify the SHA-256 hash but not authenticity. " +
|
|
1550
|
+
"Enable Ed25519 signing: `node lib/sign.js generate-keypair`. " +
|
|
1551
|
+
"Suppress this notice: export EXCEPTD_UNSIGNED_WARNED=1.\n"
|
|
1552
|
+
);
|
|
1553
|
+
process.env.EXCEPTD_UNSIGNED_WARNED = "1";
|
|
1554
|
+
}
|
|
1013
1555
|
try {
|
|
1014
1556
|
if (fs.existsSync(privKeyPath)) {
|
|
1015
1557
|
const privateKey = fs.readFileSync(privKeyPath, "utf8");
|
|
@@ -1037,21 +1579,49 @@ function maybeSignAttestation(filePath) {
|
|
|
1037
1579
|
} catch { /* non-fatal — signing failure shouldn't block the run */ }
|
|
1038
1580
|
}
|
|
1039
1581
|
|
|
1582
|
+
/**
|
|
1583
|
+
* Resolve a session-id to its on-disk directory. Searches both the v0.11.0
|
|
1584
|
+
* default root and the legacy cwd-relative root; returns whichever exists.
|
|
1585
|
+
* Returns null if neither has the session.
|
|
1586
|
+
*/
|
|
1587
|
+
function findSessionDir(sessionId, runOpts) {
|
|
1588
|
+
const candidates = [
|
|
1589
|
+
path.join(resolveAttestationRoot(runOpts), sessionId),
|
|
1590
|
+
path.join(process.cwd(), ".exceptd", "attestations", sessionId),
|
|
1591
|
+
];
|
|
1592
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1040
1596
|
/**
|
|
1041
1597
|
* Find the latest attestation file under .exceptd/attestations/.
|
|
1042
1598
|
* Filters: optional playbook ID and optional "since" ISO timestamp.
|
|
1043
1599
|
* Returns { sessionId, playbookId, file, parsed } or null.
|
|
1044
1600
|
*/
|
|
1045
1601
|
function findLatestAttestation(opts = {}) {
|
|
1046
|
-
|
|
1047
|
-
|
|
1602
|
+
// Search both the v0.11.0 default root (~/.exceptd/) and the legacy cwd-
|
|
1603
|
+
// relative root so operators with prior attestations don't lose their
|
|
1604
|
+
// history when the default moved.
|
|
1605
|
+
const roots = [resolveAttestationRoot(opts), path.join(process.cwd(), ".exceptd", "attestations")];
|
|
1606
|
+
const seen = new Set();
|
|
1607
|
+
const candidates = [];
|
|
1608
|
+
for (const root of roots) {
|
|
1609
|
+
if (seen.has(root) || !fs.existsSync(root)) continue;
|
|
1610
|
+
seen.add(root);
|
|
1611
|
+
walkAttestationDir(root, opts, candidates);
|
|
1612
|
+
}
|
|
1613
|
+
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
1614
|
+
return candidates[0] || null;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function walkAttestationDir(root, opts, candidates) {
|
|
1618
|
+
if (!fs.existsSync(root)) return;
|
|
1048
1619
|
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
1049
1620
|
.filter(d => d.isDirectory())
|
|
1050
1621
|
.map(d => d.name);
|
|
1051
|
-
const candidates = [];
|
|
1052
1622
|
for (const sid of sessions) {
|
|
1053
1623
|
const sdir = path.join(root, sid);
|
|
1054
|
-
for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
|
|
1624
|
+
for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json") && !x.endsWith(".sig"))) {
|
|
1055
1625
|
try {
|
|
1056
1626
|
const p = path.join(sdir, f);
|
|
1057
1627
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
@@ -1062,8 +1632,6 @@ function findLatestAttestation(opts = {}) {
|
|
|
1062
1632
|
} catch { /* skip malformed */ }
|
|
1063
1633
|
}
|
|
1064
1634
|
}
|
|
1065
|
-
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
1066
|
-
return candidates[0] || null;
|
|
1067
1635
|
}
|
|
1068
1636
|
|
|
1069
1637
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
@@ -1081,10 +1649,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
1081
1649
|
attFile = found.file;
|
|
1082
1650
|
}
|
|
1083
1651
|
if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
|
|
1084
|
-
const dir = path.join(
|
|
1652
|
+
const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
|
|
1085
1653
|
if (!attFile) attFile = path.join(dir, "attestation.json");
|
|
1086
1654
|
if (!fs.existsSync(attFile)) {
|
|
1087
|
-
return emitError(`reattest: no attestation found at ${
|
|
1655
|
+
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
1088
1656
|
}
|
|
1089
1657
|
let prior;
|
|
1090
1658
|
try {
|
|
@@ -1170,14 +1738,18 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
1170
1738
|
const subverb = args._[0];
|
|
1171
1739
|
const sessionId = args._[1];
|
|
1172
1740
|
if (!subverb) {
|
|
1173
|
-
return emitError("attest: missing subverb. Usage: attest export|verify|
|
|
1741
|
+
return emitError("attest: missing subverb. Usage: attest list | show <sid> | export <sid> | verify <sid> | diff <sid>", null, pretty);
|
|
1742
|
+
}
|
|
1743
|
+
// `list` doesn't require a session-id positional.
|
|
1744
|
+
if (subverb === "list") {
|
|
1745
|
+
return cmdListAttestations(runner, args, runOpts, pretty);
|
|
1174
1746
|
}
|
|
1175
1747
|
if (!sessionId) {
|
|
1176
1748
|
return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
|
|
1177
1749
|
}
|
|
1178
|
-
const dir =
|
|
1179
|
-
if (!
|
|
1180
|
-
return emitError(`attest ${subverb}: no session dir
|
|
1750
|
+
const dir = findSessionDir(sessionId, runOpts);
|
|
1751
|
+
if (!dir) {
|
|
1752
|
+
return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
|
|
1181
1753
|
}
|
|
1182
1754
|
|
|
1183
1755
|
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
@@ -1191,6 +1763,46 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
1191
1763
|
return;
|
|
1192
1764
|
}
|
|
1193
1765
|
|
|
1766
|
+
if (subverb === "diff") {
|
|
1767
|
+
// `attest diff <session-id> [--against <other-session-id>]` — drift
|
|
1768
|
+
// comparison. Without --against, replays current state against prior
|
|
1769
|
+
// session (= reattest). With --against, compares two sessions A vs B
|
|
1770
|
+
// by evidence_hash + artifact-level field diff.
|
|
1771
|
+
if (args.against) {
|
|
1772
|
+
const otherDir = findSessionDir(args.against, runOpts);
|
|
1773
|
+
if (!otherDir) {
|
|
1774
|
+
return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
|
|
1775
|
+
}
|
|
1776
|
+
const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
1777
|
+
if (otherFiles.length === 0) {
|
|
1778
|
+
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
1779
|
+
}
|
|
1780
|
+
const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
|
|
1781
|
+
const self = attestations[0];
|
|
1782
|
+
emit({
|
|
1783
|
+
verb: "attest diff",
|
|
1784
|
+
a_session: sessionId,
|
|
1785
|
+
b_session: args.against,
|
|
1786
|
+
a_captured: self.captured_at,
|
|
1787
|
+
b_captured: other.captured_at,
|
|
1788
|
+
a_evidence_hash: self.evidence_hash,
|
|
1789
|
+
b_evidence_hash: other.evidence_hash,
|
|
1790
|
+
status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
|
|
1791
|
+
artifact_diff: diffArtifacts((self.submission || {}).artifacts, (other.submission || {}).artifacts),
|
|
1792
|
+
signal_override_diff: diffSignalOverrides((self.submission || {}).signal_overrides, (other.submission || {}).signal_overrides),
|
|
1793
|
+
}, pretty);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
// Fall through to reattest-style replay below by setting subverb to a
|
|
1797
|
+
// sentinel and re-dispatching via cmdReattest.
|
|
1798
|
+
args._ = [sessionId];
|
|
1799
|
+
return cmdReattest(runner, args, {}, pretty);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (subverb === "list") {
|
|
1803
|
+
return cmdListAttestations(runner, args, {}, pretty);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1194
1806
|
if (subverb === "verify") {
|
|
1195
1807
|
const crypto = require("crypto");
|
|
1196
1808
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
@@ -1267,33 +1879,483 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
1267
1879
|
return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
|
|
1268
1880
|
}
|
|
1269
1881
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1882
|
+
/**
|
|
1883
|
+
* Per-artifact diff between two submissions. Returns { added, removed, changed }
|
|
1884
|
+
* keyed by artifact id. Used by `attest diff` (bug #34 fix) so operators get
|
|
1885
|
+
* field-level context instead of a binary evidence_hash signal.
|
|
1886
|
+
*/
|
|
1887
|
+
function diffArtifacts(a, b) {
|
|
1888
|
+
a = a || {}; b = b || {};
|
|
1889
|
+
const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
1890
|
+
const out = { added: [], removed: [], changed: [], unchanged_count: 0 };
|
|
1891
|
+
for (const id of allIds) {
|
|
1892
|
+
const av = a[id], bv = b[id];
|
|
1893
|
+
if (!av && bv) out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
|
|
1894
|
+
else if (av && !bv) out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
|
|
1895
|
+
else if (JSON.stringify(av) !== JSON.stringify(bv)) {
|
|
1896
|
+
out.changed.push({
|
|
1897
|
+
id,
|
|
1898
|
+
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
1899
|
+
a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
|
|
1900
|
+
});
|
|
1901
|
+
} else { out.unchanged_count++; }
|
|
1274
1902
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1903
|
+
return out;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function diffSignalOverrides(a, b) {
|
|
1907
|
+
a = a || {}; b = b || {};
|
|
1908
|
+
const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
1909
|
+
const out = { changed: [], unchanged_count: 0 };
|
|
1910
|
+
for (const id of allIds) {
|
|
1911
|
+
if (a[id] !== b[id]) out.changed.push({ id, a: a[id] || null, b: b[id] || null });
|
|
1912
|
+
else out.unchanged_count++;
|
|
1913
|
+
}
|
|
1914
|
+
return out;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function previewValue(v) {
|
|
1918
|
+
if (v === null || v === undefined) return null;
|
|
1919
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
1920
|
+
return s.length > 80 ? s.slice(0, 80) + "…" : s;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ---------------------------------------------------------------------------
|
|
1924
|
+
// v0.11.0: cmdDiscover — context-aware playbook recommender.
|
|
1925
|
+
// Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
|
|
1926
|
+
// /etc/os-release on Linux, and outputs a list of recommended playbooks.
|
|
1927
|
+
// ---------------------------------------------------------------------------
|
|
1928
|
+
function cmdDiscover(runner, args, runOpts, pretty) {
|
|
1929
|
+
const cwd = process.cwd();
|
|
1930
|
+
const wantJson = !!args.json || !!args.pretty;
|
|
1931
|
+
const indent = !!args.pretty;
|
|
1932
|
+
|
|
1933
|
+
// File-presence sniffer. Each probe is independently fault-tolerant so a
|
|
1934
|
+
// permission error on one path can't poison the whole detection.
|
|
1935
|
+
const detected = [];
|
|
1936
|
+
function probe(rel, label) {
|
|
1937
|
+
try {
|
|
1938
|
+
if (fs.existsSync(path.join(cwd, rel))) detected.push(label || rel);
|
|
1939
|
+
} catch { /* swallow */ }
|
|
1940
|
+
}
|
|
1941
|
+
probe(".git", ".git/");
|
|
1942
|
+
probe("package.json");
|
|
1943
|
+
probe("package-lock.json");
|
|
1944
|
+
probe("yarn.lock");
|
|
1945
|
+
probe("pnpm-lock.yaml");
|
|
1946
|
+
probe("pyproject.toml");
|
|
1947
|
+
probe("requirements.txt");
|
|
1948
|
+
probe("Pipfile");
|
|
1949
|
+
probe("Cargo.toml");
|
|
1950
|
+
probe("go.mod");
|
|
1951
|
+
probe("Dockerfile");
|
|
1952
|
+
probe("docker-compose.yml");
|
|
1953
|
+
probe("docker-compose.yaml");
|
|
1954
|
+
probe("kustomization.yaml");
|
|
1955
|
+
probe("k8s", "k8s/");
|
|
1956
|
+
probe(".env");
|
|
1957
|
+
probe(".envrc");
|
|
1958
|
+
|
|
1959
|
+
// Terraform / IaC — glob the top level for *.tf.
|
|
1960
|
+
try {
|
|
1961
|
+
const tfFiles = fs.readdirSync(cwd).filter(f => f.endsWith(".tf"));
|
|
1962
|
+
if (tfFiles.length) detected.push(`*.tf (${tfFiles.length})`);
|
|
1963
|
+
} catch { /* swallow */ }
|
|
1964
|
+
|
|
1965
|
+
// Git remote (best-effort, never fatal).
|
|
1966
|
+
let gitRemote = null;
|
|
1967
|
+
if (detected.includes(".git/")) {
|
|
1968
|
+
try {
|
|
1969
|
+
const headPath = path.join(cwd, ".git", "config");
|
|
1970
|
+
if (fs.existsSync(headPath)) {
|
|
1971
|
+
const cfg = fs.readFileSync(headPath, "utf8");
|
|
1972
|
+
const m = cfg.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(\S+)/);
|
|
1973
|
+
if (m) gitRemote = m[1];
|
|
1974
|
+
}
|
|
1975
|
+
} catch { /* swallow */ }
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// Host platform / distro.
|
|
1979
|
+
const hostPlatform = process.platform;
|
|
1980
|
+
let hostDistro = null;
|
|
1981
|
+
if (hostPlatform === "linux") {
|
|
1982
|
+
try {
|
|
1983
|
+
const res = spawnSync("cat", ["/etc/os-release"], { encoding: "utf8" });
|
|
1984
|
+
if (res.status === 0 && res.stdout) {
|
|
1985
|
+
const idMatch = res.stdout.match(/^ID=(.+)$/m);
|
|
1986
|
+
const verMatch = res.stdout.match(/^VERSION_ID=(.+)$/m);
|
|
1987
|
+
const prettyMatch = res.stdout.match(/^PRETTY_NAME=(.+)$/m);
|
|
1988
|
+
hostDistro = {
|
|
1989
|
+
id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
|
|
1990
|
+
version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
|
|
1991
|
+
pretty_name: prettyMatch ? prettyMatch[1].replace(/^"|"$/g, "") : null,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
} catch { /* swallow */ }
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Build recommendation set. Dedup by playbook id so multi-trigger rules
|
|
1998
|
+
// don't double-list.
|
|
1999
|
+
const isRepo = detected.includes(".git/");
|
|
2000
|
+
const hasNode = detected.includes("package.json") || detected.includes("package-lock.json")
|
|
2001
|
+
|| detected.includes("yarn.lock") || detected.includes("pnpm-lock.yaml");
|
|
2002
|
+
const hasPython = detected.includes("pyproject.toml") || detected.includes("requirements.txt")
|
|
2003
|
+
|| detected.includes("Pipfile");
|
|
2004
|
+
const hasRust = detected.includes("Cargo.toml");
|
|
2005
|
+
const hasGo = detected.includes("go.mod");
|
|
2006
|
+
const hasLockfile = hasNode || hasPython || hasRust || hasGo;
|
|
2007
|
+
const hasContainers = detected.includes("Dockerfile") || detected.includes("docker-compose.yml")
|
|
2008
|
+
|| detected.includes("docker-compose.yaml");
|
|
2009
|
+
const isLinux = hostPlatform === "linux";
|
|
2010
|
+
|
|
2011
|
+
const recs = [];
|
|
2012
|
+
const seen = new Set();
|
|
2013
|
+
function recommend(id, reason) {
|
|
2014
|
+
if (seen.has(id)) return;
|
|
2015
|
+
seen.add(id);
|
|
2016
|
+
recs.push({ id, reason });
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
if (isRepo && hasLockfile) {
|
|
2020
|
+
const langs = [hasNode && "node", hasPython && "python", hasRust && "rust", hasGo && "go"]
|
|
2021
|
+
.filter(Boolean).join("/");
|
|
2022
|
+
recommend("secrets", `git repo + ${langs} lockfile → check for committed credentials`);
|
|
2023
|
+
recommend("sbom", `git repo + ${langs} lockfile → SBOM + supply-chain integrity`);
|
|
2024
|
+
recommend("library-author", `git repo + ${langs} lockfile → publisher-side audit`);
|
|
2025
|
+
recommend("crypto-codebase", `git repo + ${langs} lockfile → cryptographic primitive review`);
|
|
2026
|
+
}
|
|
2027
|
+
if (hasContainers) {
|
|
2028
|
+
recommend("containers", "Dockerfile / docker-compose present → container security review");
|
|
2029
|
+
}
|
|
2030
|
+
if (isLinux) {
|
|
2031
|
+
recommend("kernel", "Linux host detected → kernel LPE / privilege escalation triage");
|
|
2032
|
+
recommend("hardening", "Linux host detected → system hardening review");
|
|
2033
|
+
recommend("runtime", "Linux host detected → runtime behavior review");
|
|
2034
|
+
recommend("cred-stores", "Linux host detected → credential store review");
|
|
2035
|
+
}
|
|
2036
|
+
// Always include cross-cutting framework correlation.
|
|
2037
|
+
recommend("framework", "cross-cutting: framework correlation always applicable");
|
|
2038
|
+
|
|
2039
|
+
const nextSteps = [
|
|
2040
|
+
"exceptd brief <playbook> # learn what a playbook checks",
|
|
2041
|
+
"exceptd run <playbook> # run it",
|
|
2042
|
+
"exceptd run --scope code # run all code-scoped playbooks (auto-detected)",
|
|
2043
|
+
"exceptd ci --scope code # CI-gate against all code-scoped playbooks",
|
|
2044
|
+
];
|
|
2045
|
+
|
|
2046
|
+
const out = {
|
|
2047
|
+
verb: "discover",
|
|
2048
|
+
context: {
|
|
2049
|
+
cwd,
|
|
2050
|
+
git_remote: gitRemote,
|
|
2051
|
+
detected_files: detected,
|
|
2052
|
+
host_platform: hostPlatform,
|
|
2053
|
+
host_distro: hostDistro,
|
|
2054
|
+
},
|
|
2055
|
+
recommended_playbooks: recs,
|
|
2056
|
+
next_steps: nextSteps,
|
|
2057
|
+
};
|
|
1278
2058
|
|
|
2059
|
+
// --scan-only: also run legacy `scan` and embed under legacy_scan. Use
|
|
2060
|
+
// spawnSync against orchestrator/index.js — the orchestrator was designed
|
|
2061
|
+
// to be invoked as a subprocess, and isolating it via spawn prevents one
|
|
2062
|
+
// bad scanner from killing the whole discover verb.
|
|
2063
|
+
if (args["scan-only"]) {
|
|
2064
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2065
|
+
try {
|
|
2066
|
+
const res = spawnSync(process.execPath, [orchPath, "scan", "--json"], {
|
|
2067
|
+
encoding: "utf8",
|
|
2068
|
+
cwd,
|
|
2069
|
+
timeout: 30000,
|
|
2070
|
+
});
|
|
2071
|
+
if (res.status === 0 && res.stdout) {
|
|
2072
|
+
try { out.legacy_scan = JSON.parse(res.stdout); }
|
|
2073
|
+
catch { out.legacy_scan = { ok: false, raw: res.stdout.slice(0, 2000), parse_error: true }; }
|
|
2074
|
+
} else {
|
|
2075
|
+
out.legacy_scan = {
|
|
2076
|
+
ok: false,
|
|
2077
|
+
exit_code: res.status,
|
|
2078
|
+
stderr: (res.stderr || "").slice(0, 2000),
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
} catch (e) {
|
|
2082
|
+
out.legacy_scan = { ok: false, error: e.message };
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
if (wantJson) {
|
|
2087
|
+
emit(out, indent);
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// Default: human-readable text. (v0.11.0 redesign #5 — flipped defaults.)
|
|
2092
|
+
const lines = [];
|
|
2093
|
+
lines.push("exceptd discover");
|
|
2094
|
+
lines.push(` cwd: ${cwd}`);
|
|
2095
|
+
if (gitRemote) lines.push(` git remote: ${gitRemote}`);
|
|
2096
|
+
lines.push(` platform: ${hostPlatform}${hostDistro && hostDistro.pretty_name ? " (" + hostDistro.pretty_name + ")" : ""}`);
|
|
2097
|
+
lines.push(` detected: ${detected.length ? detected.join(", ") : "(nothing recognized)"}`);
|
|
2098
|
+
lines.push("");
|
|
2099
|
+
lines.push(`Recommended playbooks (${recs.length}):`);
|
|
2100
|
+
for (const r of recs) {
|
|
2101
|
+
lines.push(` - ${r.id.padEnd(20)} ${r.reason}`);
|
|
2102
|
+
}
|
|
2103
|
+
lines.push("");
|
|
2104
|
+
lines.push("Next steps:");
|
|
2105
|
+
for (const s of nextSteps) lines.push(` ${s}`);
|
|
2106
|
+
if (out.legacy_scan) {
|
|
2107
|
+
lines.push("");
|
|
2108
|
+
lines.push(`legacy scan: ${out.legacy_scan.ok === false ? "FAILED" : "ok"}`);
|
|
2109
|
+
}
|
|
2110
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// ---------------------------------------------------------------------------
|
|
2114
|
+
// v0.11.0: cmdDoctor — one-shot health check.
|
|
2115
|
+
// Collapses verify + currency + validate-cves + validate-rfcs + signing-status.
|
|
2116
|
+
// Each subcheck is independently fault-tolerant: a single failure surfaces
|
|
2117
|
+
// in the JSON but never crashes the verb.
|
|
2118
|
+
// ---------------------------------------------------------------------------
|
|
2119
|
+
function cmdDoctor(runner, args, runOpts, pretty) {
|
|
2120
|
+
const wantJson = !!args.json || !!args.pretty;
|
|
2121
|
+
const indent = !!args.pretty;
|
|
2122
|
+
|
|
2123
|
+
// Selective subchecks. If any of the four flags is passed, run only those.
|
|
2124
|
+
// If none are passed, run all four plus signing-status.
|
|
2125
|
+
const onlySigs = !!args.signatures;
|
|
2126
|
+
const onlyCurrency = !!args.currency;
|
|
2127
|
+
const onlyCves = !!args.cves;
|
|
2128
|
+
const onlyRfcs = !!args.rfcs;
|
|
2129
|
+
const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs;
|
|
2130
|
+
const runSigs = !anySelected || onlySigs;
|
|
2131
|
+
const runCurrency = !anySelected || onlyCurrency;
|
|
2132
|
+
const runCves = !anySelected || onlyCves;
|
|
2133
|
+
const runRfcs = !anySelected || onlyRfcs;
|
|
2134
|
+
const runSigning = !anySelected;
|
|
2135
|
+
|
|
2136
|
+
const checks = {};
|
|
2137
|
+
const issues = [];
|
|
2138
|
+
|
|
2139
|
+
if (runSigs) {
|
|
2140
|
+
try {
|
|
2141
|
+
const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
|
|
2142
|
+
const res = spawnSync(process.execPath, [verifyPath], {
|
|
2143
|
+
encoding: "utf8",
|
|
2144
|
+
cwd: PKG_ROOT,
|
|
2145
|
+
timeout: 30000,
|
|
2146
|
+
});
|
|
2147
|
+
const text = (res.stdout || "") + (res.stderr || "");
|
|
2148
|
+
const okMatch = text.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
|
|
2149
|
+
const fpMatch = text.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
|
|
2150
|
+
const ok = res.status === 0;
|
|
2151
|
+
checks.signatures = {
|
|
2152
|
+
ok,
|
|
2153
|
+
skills_passed: okMatch ? Number(okMatch[1]) : null,
|
|
2154
|
+
skills_total: okMatch ? Number(okMatch[2]) : null,
|
|
2155
|
+
fingerprint_sha256: fpMatch ? fpMatch[1] : null,
|
|
2156
|
+
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2157
|
+
};
|
|
2158
|
+
if (!ok) issues.push("signatures");
|
|
2159
|
+
} catch (e) {
|
|
2160
|
+
checks.signatures = { ok: false, error: e.message };
|
|
2161
|
+
issues.push("signatures");
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
if (runCurrency) {
|
|
2166
|
+
try {
|
|
2167
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2168
|
+
const res = spawnSync(process.execPath, [orchPath, "currency", "--json"], {
|
|
2169
|
+
encoding: "utf8",
|
|
2170
|
+
cwd: PKG_ROOT,
|
|
2171
|
+
timeout: 30000,
|
|
2172
|
+
});
|
|
2173
|
+
let parsed = null;
|
|
2174
|
+
if (res.stdout) {
|
|
2175
|
+
const m = res.stdout.match(/\{[\s\S]*\}\s*$/);
|
|
2176
|
+
if (m) {
|
|
2177
|
+
try { parsed = JSON.parse(m[0]); } catch { /* fall through */ }
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (parsed && Array.isArray(parsed.currency_report)) {
|
|
2181
|
+
const stale = parsed.currency_report.filter(s => s.action_required || s.currency_label !== "current");
|
|
2182
|
+
const critical = parsed.currency_report.filter(s => s.currency_score !== undefined && s.currency_score < 50);
|
|
2183
|
+
const ok = stale.length === 0 && !parsed.action_required;
|
|
2184
|
+
checks.currency = {
|
|
2185
|
+
ok,
|
|
2186
|
+
total_skills: parsed.currency_report.length,
|
|
2187
|
+
stale_skills: stale.map(s => s.skill),
|
|
2188
|
+
critical_stale: critical.map(s => s.skill),
|
|
2189
|
+
critical_count: parsed.critical_count || 0,
|
|
2190
|
+
};
|
|
2191
|
+
if (!ok) issues.push("currency");
|
|
2192
|
+
} else {
|
|
2193
|
+
checks.currency = {
|
|
2194
|
+
ok: res.status === 0,
|
|
2195
|
+
exit_code: res.status,
|
|
2196
|
+
raw: (res.stdout || res.stderr || "").slice(0, 500),
|
|
2197
|
+
parse_error: true,
|
|
2198
|
+
};
|
|
2199
|
+
if (res.status !== 0) issues.push("currency");
|
|
2200
|
+
}
|
|
2201
|
+
} catch (e) {
|
|
2202
|
+
checks.currency = { ok: false, error: e.message };
|
|
2203
|
+
issues.push("currency");
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
if (runCves) {
|
|
2208
|
+
try {
|
|
2209
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2210
|
+
// validate-cves doesn't emit JSON; parse text for row count + drift.
|
|
2211
|
+
const res = spawnSync(process.execPath, [orchPath, "validate-cves", "--offline"], {
|
|
2212
|
+
encoding: "utf8",
|
|
2213
|
+
cwd: PKG_ROOT,
|
|
2214
|
+
timeout: 30000,
|
|
2215
|
+
});
|
|
2216
|
+
const text = (res.stdout || "") + (res.stderr || "");
|
|
2217
|
+
const totalMatch = text.match(/(\d+)\s+CVEs?\s+in\s+catalog/i);
|
|
2218
|
+
const driftMatch = text.match(/drift[:\s]+(\d+)/i);
|
|
2219
|
+
const ok = res.status === 0;
|
|
2220
|
+
checks.cves = {
|
|
2221
|
+
ok,
|
|
2222
|
+
total: totalMatch ? Number(totalMatch[1]) : null,
|
|
2223
|
+
drift: driftMatch ? Number(driftMatch[1]) : 0,
|
|
2224
|
+
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2225
|
+
};
|
|
2226
|
+
if (!ok) issues.push("cves");
|
|
2227
|
+
} catch (e) {
|
|
2228
|
+
checks.cves = { ok: false, error: e.message };
|
|
2229
|
+
issues.push("cves");
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if (runRfcs) {
|
|
2234
|
+
try {
|
|
2235
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2236
|
+
const res = spawnSync(process.execPath, [orchPath, "validate-rfcs", "--offline"], {
|
|
2237
|
+
encoding: "utf8",
|
|
2238
|
+
cwd: PKG_ROOT,
|
|
2239
|
+
timeout: 30000,
|
|
2240
|
+
});
|
|
2241
|
+
const text = (res.stdout || "") + (res.stderr || "");
|
|
2242
|
+
const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
|
|
2243
|
+
const driftMatch = text.match(/drift[:\s]+(\d+)/i);
|
|
2244
|
+
const ok = res.status === 0;
|
|
2245
|
+
checks.rfcs = {
|
|
2246
|
+
ok,
|
|
2247
|
+
total: rfcRows,
|
|
2248
|
+
drift: driftMatch ? Number(driftMatch[1]) : 0,
|
|
2249
|
+
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2250
|
+
};
|
|
2251
|
+
if (!ok) issues.push("rfcs");
|
|
2252
|
+
} catch (e) {
|
|
2253
|
+
checks.rfcs = { ok: false, error: e.message };
|
|
2254
|
+
issues.push("rfcs");
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
if (runSigning) {
|
|
2259
|
+
try {
|
|
2260
|
+
const keyPath = path.join(process.cwd(), ".keys", "private.pem");
|
|
2261
|
+
const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2262
|
+
const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
|
|
2263
|
+
checks.signing = {
|
|
2264
|
+
ok: true, // signing-status is informational, never "fails"
|
|
2265
|
+
private_key_present: present,
|
|
2266
|
+
can_sign_attestations: present,
|
|
2267
|
+
...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` to enable attestation signing" }),
|
|
2268
|
+
};
|
|
2269
|
+
} catch (e) {
|
|
2270
|
+
checks.signing = { ok: false, error: e.message };
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
const allGreen = issues.length === 0;
|
|
2275
|
+
const out = {
|
|
2276
|
+
verb: "doctor",
|
|
2277
|
+
checks,
|
|
2278
|
+
summary: { all_green: allGreen, issues_count: issues.length, failed_checks: issues },
|
|
2279
|
+
};
|
|
2280
|
+
|
|
2281
|
+
if (wantJson) {
|
|
2282
|
+
emit(out, indent);
|
|
2283
|
+
if (!allGreen) process.exitCode = 1;
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// Default: human checklist. v0.11.0 redesign #5.
|
|
2288
|
+
const lines = [];
|
|
2289
|
+
lines.push("exceptd doctor");
|
|
2290
|
+
function mark(c, render) {
|
|
2291
|
+
if (!c) return;
|
|
2292
|
+
const icon = c.ok ? "[ok]" : "[!!]";
|
|
2293
|
+
lines.push(` ${icon} ${render(c)}`);
|
|
2294
|
+
}
|
|
2295
|
+
mark(checks.signatures, c =>
|
|
2296
|
+
c.ok
|
|
2297
|
+
? `skill signatures verified (${c.skills_passed ?? "?"}/${c.skills_total ?? "?"})`
|
|
2298
|
+
: `skill signatures FAILED (exit=${c.exit_code ?? "?"})`
|
|
2299
|
+
);
|
|
2300
|
+
mark(checks.currency, c =>
|
|
2301
|
+
c.ok
|
|
2302
|
+
? `skill currency: all green (${c.total_skills ?? "?"} skills)`
|
|
2303
|
+
: `skill currency: ${c.stale_skills?.length || "?"} stale, ${c.critical_count ?? 0} critical`
|
|
2304
|
+
);
|
|
2305
|
+
mark(checks.cves, c =>
|
|
2306
|
+
c.ok
|
|
2307
|
+
? `CVE catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
|
|
2308
|
+
: `CVE catalog FAILED (exit=${c.exit_code ?? "?"})`
|
|
2309
|
+
);
|
|
2310
|
+
mark(checks.rfcs, c =>
|
|
2311
|
+
c.ok
|
|
2312
|
+
? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
|
|
2313
|
+
: `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
|
|
2314
|
+
);
|
|
2315
|
+
if (checks.signing) {
|
|
2316
|
+
if (checks.signing.private_key_present) {
|
|
2317
|
+
lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
|
|
2318
|
+
} else {
|
|
2319
|
+
lines.push(` [!!] attestation signing: private key MISSING (.keys/private.pem) — run \`node lib/sign.js generate-keypair\` to enable`);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
lines.push("");
|
|
2323
|
+
lines.push(allGreen ? `summary: all checks green` : `summary: ${issues.length} issue(s) — ${issues.join(", ")}`);
|
|
2324
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2325
|
+
if (!allGreen) process.exitCode = 1;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
2329
|
+
// Enumerate sessions across both v0.11.0 default root and legacy cwd-
|
|
2330
|
+
// relative root, so operators with prior attestations still see them.
|
|
2331
|
+
const roots = [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")];
|
|
1279
2332
|
const entries = [];
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
2333
|
+
const seenRoots = new Set();
|
|
2334
|
+
for (const root of roots) {
|
|
2335
|
+
if (seenRoots.has(root) || !fs.existsSync(root)) continue;
|
|
2336
|
+
seenRoots.add(root);
|
|
2337
|
+
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
2338
|
+
.filter(d => d.isDirectory())
|
|
2339
|
+
.map(d => d.name);
|
|
2340
|
+
for (const sid of sessions) {
|
|
2341
|
+
const sdir = path.join(root, sid);
|
|
2342
|
+
const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
2343
|
+
for (const f of files) {
|
|
2344
|
+
try {
|
|
2345
|
+
const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
|
|
2346
|
+
if (args.playbook && j.playbook_id !== args.playbook) continue;
|
|
2347
|
+
if (args.since && (j.captured_at || "") < args.since) continue;
|
|
2348
|
+
entries.push({
|
|
2349
|
+
session_id: sid,
|
|
2350
|
+
playbook_id: j.playbook_id,
|
|
2351
|
+
directive_id: j.directive_id,
|
|
2352
|
+
evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
|
|
2353
|
+
captured_at: j.captured_at || null,
|
|
2354
|
+
attestation_root: root,
|
|
2355
|
+
file: path.join(sdir, f),
|
|
2356
|
+
});
|
|
2357
|
+
} catch { /* skip malformed */ }
|
|
2358
|
+
}
|
|
1297
2359
|
}
|
|
1298
2360
|
}
|
|
1299
2361
|
entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
|
|
@@ -1301,10 +2363,423 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
1301
2363
|
ok: true,
|
|
1302
2364
|
attestations: entries,
|
|
1303
2365
|
count: entries.length,
|
|
1304
|
-
filter: { playbook: args.playbook || null },
|
|
2366
|
+
filter: { playbook: args.playbook || null, since: args.since || null },
|
|
2367
|
+
roots_searched: [...seenRoots],
|
|
1305
2368
|
}, pretty);
|
|
1306
2369
|
}
|
|
1307
2370
|
|
|
2371
|
+
// ---------------------------------------------------------------------------
|
|
2372
|
+
// v0.11.0 verbs: ai-run, ask, ci
|
|
2373
|
+
// ---------------------------------------------------------------------------
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* `ai-run <playbook>` — streaming JSONL contract for AI-driven runs.
|
|
2377
|
+
*
|
|
2378
|
+
* Emits one JSON object per line over stdout as the seven phases progress;
|
|
2379
|
+
* reads {"event":"evidence","payload":{observations,verdict}} from stdin
|
|
2380
|
+
* once it's announced the await_evidence phase. Designed so a host AI can
|
|
2381
|
+
* pipe one bidirectional channel instead of doing brief → look → run as
|
|
2382
|
+
* three CLI round-trips with an intermediate evidence file.
|
|
2383
|
+
*
|
|
2384
|
+
* --no-stream falls back to a single JSON document combining every phase
|
|
2385
|
+
* for callers that don't want event-driven I/O (smoke tests, batch jobs).
|
|
2386
|
+
*/
|
|
2387
|
+
function cmdAiRun(runner, args, runOpts, pretty) {
|
|
2388
|
+
const playbookId = args._[0];
|
|
2389
|
+
if (!playbookId) {
|
|
2390
|
+
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
2391
|
+
}
|
|
2392
|
+
let pb;
|
|
2393
|
+
try { pb = runner.loadPlaybook(playbookId); }
|
|
2394
|
+
catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
|
|
2395
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2396
|
+
if (!directiveId) {
|
|
2397
|
+
return emitError(`ai-run: playbook ${playbookId} has no directives.`, null, pretty);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// Compute the informational phases up front — both stream and no-stream
|
|
2401
|
+
// modes share them.
|
|
2402
|
+
let governPhase, directPhase, lookPhase;
|
|
2403
|
+
try {
|
|
2404
|
+
governPhase = runner.govern(playbookId, directiveId, runOpts);
|
|
2405
|
+
directPhase = runner.direct(playbookId, directiveId);
|
|
2406
|
+
lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
2407
|
+
} catch (e) {
|
|
2408
|
+
process.stdout.write(JSON.stringify({ event: "error", reason: e.message, phase: "info" }) + "\n");
|
|
2409
|
+
process.exit(1);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const governEvent = {
|
|
2413
|
+
phase: "govern",
|
|
2414
|
+
playbook_id: playbookId,
|
|
2415
|
+
directive_id: directiveId,
|
|
2416
|
+
jurisdiction_obligations: governPhase.jurisdiction_obligations || [],
|
|
2417
|
+
theater_fingerprints: governPhase.theater_fingerprints || [],
|
|
2418
|
+
framework_context: governPhase.framework_context || null,
|
|
2419
|
+
skill_preload: governPhase.skill_preload || [],
|
|
2420
|
+
};
|
|
2421
|
+
const directEvent = {
|
|
2422
|
+
phase: "direct",
|
|
2423
|
+
threat_context: directPhase.threat_context || null,
|
|
2424
|
+
rwep_threshold: directPhase.rwep_threshold || null,
|
|
2425
|
+
framework_lag_declaration: directPhase.framework_lag_declaration || null,
|
|
2426
|
+
skill_chain: directPhase.skill_chain || [],
|
|
2427
|
+
token_budget: directPhase.token_budget || null,
|
|
2428
|
+
};
|
|
2429
|
+
const lookEvent = {
|
|
2430
|
+
phase: "look",
|
|
2431
|
+
artifacts_required: (lookPhase.artifacts || []).filter(a => a.required),
|
|
2432
|
+
artifacts_optional: (lookPhase.artifacts || []).filter(a => !a.required),
|
|
2433
|
+
preconditions: lookPhase.preconditions || [],
|
|
2434
|
+
precondition_submission_shape: lookPhase.precondition_submission_shape || null,
|
|
2435
|
+
collection_scope: lookPhase.collection_scope || null,
|
|
2436
|
+
};
|
|
2437
|
+
const submissionShape = {
|
|
2438
|
+
observations: {},
|
|
2439
|
+
verdict: {},
|
|
2440
|
+
note: "Send back as {\"event\":\"evidence\",\"payload\":{\"observations\":{...},\"verdict\":{...}}}.",
|
|
2441
|
+
};
|
|
2442
|
+
|
|
2443
|
+
// ----- single-shot path -----
|
|
2444
|
+
if (args["no-stream"]) {
|
|
2445
|
+
// Read any pre-supplied evidence from stdin OR from --evidence flag.
|
|
2446
|
+
let payload = { observations: {}, verdict: {} };
|
|
2447
|
+
if (args.evidence) {
|
|
2448
|
+
try { payload = readEvidence(args.evidence); }
|
|
2449
|
+
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
2450
|
+
} else if (!process.stdin.isTTY) {
|
|
2451
|
+
// Drain stdin for any evidence event.
|
|
2452
|
+
try {
|
|
2453
|
+
const buf = fs.readFileSync(0, "utf8");
|
|
2454
|
+
if (buf.trim()) {
|
|
2455
|
+
// Accept either a bare submission object or a single evidence event.
|
|
2456
|
+
for (const line of buf.split(/\r?\n/)) {
|
|
2457
|
+
const t = line.trim();
|
|
2458
|
+
if (!t) continue;
|
|
2459
|
+
try {
|
|
2460
|
+
const parsed = JSON.parse(t);
|
|
2461
|
+
if (parsed && parsed.event === "evidence" && parsed.payload) {
|
|
2462
|
+
payload = parsed.payload;
|
|
2463
|
+
break;
|
|
2464
|
+
}
|
|
2465
|
+
// Bare submission fallback.
|
|
2466
|
+
if (parsed && (parsed.observations || parsed.artifacts || parsed.signal_overrides)) {
|
|
2467
|
+
payload = parsed.observations
|
|
2468
|
+
? parsed
|
|
2469
|
+
: { observations: { ...(parsed.artifacts || {}), ...(parsed.signal_overrides || {}) }, verdict: parsed.signals || {} };
|
|
2470
|
+
break;
|
|
2471
|
+
}
|
|
2472
|
+
} catch { /* skip non-JSON lines */ }
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
} catch { /* stdin empty / unreadable — fall through with empty payload */ }
|
|
2476
|
+
}
|
|
2477
|
+
const submission = buildSubmissionFromPayload(payload);
|
|
2478
|
+
let result;
|
|
2479
|
+
try {
|
|
2480
|
+
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
2481
|
+
} catch (e) {
|
|
2482
|
+
return emitError(`ai-run: runner threw: ${e.message}`, { playbook: playbookId }, pretty);
|
|
2483
|
+
}
|
|
2484
|
+
if (!result || result.ok === false) {
|
|
2485
|
+
process.stderr.write((pretty ? JSON.stringify(result || {}, null, 2) : JSON.stringify(result || {})) + "\n");
|
|
2486
|
+
process.exit(1);
|
|
2487
|
+
}
|
|
2488
|
+
emit({
|
|
2489
|
+
verb: "ai-run",
|
|
2490
|
+
mode: "no-stream",
|
|
2491
|
+
playbook_id: playbookId,
|
|
2492
|
+
directive_id: directiveId,
|
|
2493
|
+
govern: governEvent,
|
|
2494
|
+
direct: directEvent,
|
|
2495
|
+
look: lookEvent,
|
|
2496
|
+
detect: result.phases?.detect || null,
|
|
2497
|
+
analyze: result.phases?.analyze || null,
|
|
2498
|
+
validate: result.phases?.validate || null,
|
|
2499
|
+
close: result.phases?.close || null,
|
|
2500
|
+
session_id: result.session_id,
|
|
2501
|
+
evidence_hash: result.evidence_hash,
|
|
2502
|
+
}, pretty);
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// ----- streaming path -----
|
|
2507
|
+
// Emit info phases immediately, then wait for an evidence event on stdin.
|
|
2508
|
+
const writeLine = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
|
2509
|
+
writeLine(governEvent);
|
|
2510
|
+
writeLine(directEvent);
|
|
2511
|
+
writeLine(lookEvent);
|
|
2512
|
+
writeLine({ phase: "await_evidence", submission_shape: submissionShape });
|
|
2513
|
+
|
|
2514
|
+
let handled = false;
|
|
2515
|
+
let buf = "";
|
|
2516
|
+
|
|
2517
|
+
const handleLine = (line) => {
|
|
2518
|
+
if (handled) return;
|
|
2519
|
+
let parsed;
|
|
2520
|
+
try { parsed = JSON.parse(line); }
|
|
2521
|
+
catch (e) {
|
|
2522
|
+
writeLine({ event: "error", reason: `invalid JSON on stdin: ${e.message}`, line_preview: line.slice(0, 120) });
|
|
2523
|
+
process.exit(1);
|
|
2524
|
+
}
|
|
2525
|
+
if (!parsed || parsed.event !== "evidence" || !parsed.payload) {
|
|
2526
|
+
// Ignore non-evidence chatter so the host AI can interleave its own
|
|
2527
|
+
// status events; only an "evidence" event triggers phases 4-7.
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
handled = true;
|
|
2531
|
+
const submission = buildSubmissionFromPayload(parsed.payload);
|
|
2532
|
+
let result;
|
|
2533
|
+
try {
|
|
2534
|
+
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
2535
|
+
} catch (e) {
|
|
2536
|
+
writeLine({ event: "error", reason: `runner threw: ${e.message}` });
|
|
2537
|
+
process.exit(1);
|
|
2538
|
+
}
|
|
2539
|
+
if (!result || result.ok === false) {
|
|
2540
|
+
writeLine({ event: "error", reason: result?.reason || "runner returned ok:false", result });
|
|
2541
|
+
process.exit(1);
|
|
2542
|
+
}
|
|
2543
|
+
writeLine({ phase: "detect", ...result.phases?.detect });
|
|
2544
|
+
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
2545
|
+
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
2546
|
+
writeLine({ phase: "close", ...result.phases?.close });
|
|
2547
|
+
writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
|
|
2548
|
+
process.exit(0);
|
|
2549
|
+
};
|
|
2550
|
+
|
|
2551
|
+
// Handle empty/closed stdin: emit a hint then exit cleanly so AI agents
|
|
2552
|
+
// calling ai-run without piping anything see a useful message rather than
|
|
2553
|
+
// a hung process.
|
|
2554
|
+
if (process.stdin.isTTY) {
|
|
2555
|
+
writeLine({ event: "error", reason: "ai-run streaming mode requires evidence on stdin; pipe {\"event\":\"evidence\",\"payload\":{...}} or use --no-stream." });
|
|
2556
|
+
process.exit(1);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
process.stdin.on("data", (chunk) => {
|
|
2560
|
+
buf += chunk.toString();
|
|
2561
|
+
let nl;
|
|
2562
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
2563
|
+
const line = buf.slice(0, nl).trim();
|
|
2564
|
+
buf = buf.slice(nl + 1);
|
|
2565
|
+
if (line) handleLine(line);
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
process.stdin.on("end", () => {
|
|
2569
|
+
// Final flush — handle a trailing line without a newline.
|
|
2570
|
+
const tail = buf.trim();
|
|
2571
|
+
if (tail) handleLine(tail);
|
|
2572
|
+
if (!handled) {
|
|
2573
|
+
writeLine({ event: "error", reason: "stdin closed without an evidence event." });
|
|
2574
|
+
process.exit(1);
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* Coerce a stdin payload into the runner submission shape. Accepts both the
|
|
2581
|
+
* v0.11.0 ai-run shape (observations + verdict) and the nested v0.10.x shape
|
|
2582
|
+
* (artifacts + signal_overrides + signals) for forward/back compat.
|
|
2583
|
+
*/
|
|
2584
|
+
function buildSubmissionFromPayload(payload) {
|
|
2585
|
+
if (!payload || typeof payload !== "object") return { artifacts: {}, signal_overrides: {}, signals: {} };
|
|
2586
|
+
// Nested v0.10.x shape passthrough.
|
|
2587
|
+
if (payload.artifacts || payload.signal_overrides || payload.signals) {
|
|
2588
|
+
return {
|
|
2589
|
+
artifacts: payload.artifacts || {},
|
|
2590
|
+
signal_overrides: payload.signal_overrides || {},
|
|
2591
|
+
signals: payload.signals || {},
|
|
2592
|
+
precondition_checks: payload.precondition_checks || undefined,
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
// v0.11.0 flat shape: observations becomes the artifacts+signal_overrides
|
|
2596
|
+
// union (the runner normalises both via normalizeSubmission), verdict
|
|
2597
|
+
// becomes signals.
|
|
2598
|
+
return {
|
|
2599
|
+
artifacts: payload.observations || {},
|
|
2600
|
+
signal_overrides: payload.observations || {},
|
|
2601
|
+
signals: payload.verdict || {},
|
|
2602
|
+
precondition_checks: payload.precondition_checks || undefined,
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* `ask "<question>"` — plain-English routing. Scores every playbook by token
|
|
2608
|
+
* overlap against domain.name + domain.attack_class + first sentence of
|
|
2609
|
+
* phases.direct.threat_context. Returns the top 5 matches with a confidence
|
|
2610
|
+
* score (matched tokens / total tokens).
|
|
2611
|
+
*/
|
|
2612
|
+
function cmdAsk(runner, args, runOpts, pretty) {
|
|
2613
|
+
const question = (args._ || []).join(" ").trim();
|
|
2614
|
+
if (!question) {
|
|
2615
|
+
return emitError("ask: usage: exceptd ask \"<plain-English question>\"", null, pretty);
|
|
2616
|
+
}
|
|
2617
|
+
const ids = runner.listPlaybooks();
|
|
2618
|
+
const q = question.toLowerCase();
|
|
2619
|
+
const tokens = q.split(/\W+/).filter(t => t.length > 3);
|
|
2620
|
+
const scored = [];
|
|
2621
|
+
for (const id of ids) {
|
|
2622
|
+
let pb;
|
|
2623
|
+
try { pb = runner.loadPlaybook(id); } catch { continue; }
|
|
2624
|
+
const threat = pb.phases?.direct?.threat_context || "";
|
|
2625
|
+
const firstSentence = threat.split(/(?<=[.!?])\s+/)[0] || "";
|
|
2626
|
+
const haystack = [
|
|
2627
|
+
pb.domain?.name || "",
|
|
2628
|
+
pb.domain?.attack_class || "",
|
|
2629
|
+
firstSentence,
|
|
2630
|
+
].join(" ").toLowerCase();
|
|
2631
|
+
const score = tokens.filter(t => haystack.includes(t)).length;
|
|
2632
|
+
scored.push({ id: pb._meta?.id || id, score });
|
|
2633
|
+
}
|
|
2634
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2635
|
+
const top = scored.filter(s => s.score > 0).slice(0, 5);
|
|
2636
|
+
|
|
2637
|
+
if (top.length === 0) {
|
|
2638
|
+
emit({
|
|
2639
|
+
verb: "ask",
|
|
2640
|
+
question,
|
|
2641
|
+
matched: [],
|
|
2642
|
+
hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
|
|
2643
|
+
}, pretty);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
emit({
|
|
2648
|
+
verb: "ask",
|
|
2649
|
+
question,
|
|
2650
|
+
routed_to: top.map(t => t.id),
|
|
2651
|
+
confidence: top[0].score / Math.max(1, tokens.length),
|
|
2652
|
+
next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
|
|
2653
|
+
full_match_list: top,
|
|
2654
|
+
}, pretty);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* `ci [--all|--scope <type>]` — top-level CI gate. Effectively
|
|
2659
|
+
* `run --all --ci` packaged as a verb so .github/workflows lines are short.
|
|
2660
|
+
*
|
|
2661
|
+
* Exit codes:
|
|
2662
|
+
* 0 PASS — no detected findings, no rwep ≥ cap, no clock started (when
|
|
2663
|
+
* --block-on-jurisdiction-clock is set).
|
|
2664
|
+
* 2 FAIL — any of the above tripped.
|
|
2665
|
+
*/
|
|
2666
|
+
function cmdCi(runner, args, runOpts, pretty) {
|
|
2667
|
+
const scope = args.scope;
|
|
2668
|
+
const maxRwep = args["max-rwep"] !== undefined ? Number(args["max-rwep"]) : null;
|
|
2669
|
+
const blockOnClock = !!args["block-on-jurisdiction-clock"];
|
|
2670
|
+
|
|
2671
|
+
let ids;
|
|
2672
|
+
if (args.all) {
|
|
2673
|
+
ids = runner.listPlaybooks();
|
|
2674
|
+
} else if (scope) {
|
|
2675
|
+
ids = filterPlaybooksByScope(runner, scope);
|
|
2676
|
+
} else {
|
|
2677
|
+
const scopes = detectScopes();
|
|
2678
|
+
ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
|
2679
|
+
ids = [...new Set(ids)];
|
|
2680
|
+
}
|
|
2681
|
+
if (!ids || ids.length === 0) {
|
|
2682
|
+
return emitError("ci: no playbooks matched. Pass --all, --scope <type>, or run from a repo/Linux-host context.", null, pretty);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
|
|
2686
|
+
|
|
2687
|
+
// Evidence: --evidence <file> or --evidence-dir <dir>. Both produce a
|
|
2688
|
+
// bundle keyed by playbook id; ids without a key get an empty submission.
|
|
2689
|
+
let bundle = {};
|
|
2690
|
+
if (args.evidence) {
|
|
2691
|
+
try { bundle = readEvidence(args.evidence); }
|
|
2692
|
+
catch (e) { return emitError(`ci: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
2693
|
+
}
|
|
2694
|
+
if (args["evidence-dir"]) {
|
|
2695
|
+
const dir = args["evidence-dir"];
|
|
2696
|
+
if (!fs.existsSync(dir)) {
|
|
2697
|
+
return emitError(`ci: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
2698
|
+
}
|
|
2699
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
2700
|
+
try {
|
|
2701
|
+
bundle[f.replace(/\.json$/, "")] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2702
|
+
} catch (e) {
|
|
2703
|
+
return emitError(`ci: failed to parse evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const results = [];
|
|
2709
|
+
let fail = false;
|
|
2710
|
+
let failReasons = [];
|
|
2711
|
+
|
|
2712
|
+
for (const id of ids) {
|
|
2713
|
+
let pb;
|
|
2714
|
+
try { pb = runner.loadPlaybook(id); }
|
|
2715
|
+
catch (e) { results.push({ playbook_id: id, ok: false, error: e.message }); fail = true; continue; }
|
|
2716
|
+
const directiveId = (pb.directives[0] && pb.directives[0].id);
|
|
2717
|
+
if (!directiveId) {
|
|
2718
|
+
results.push({ playbook_id: id, ok: false, error: "no directives" });
|
|
2719
|
+
fail = true;
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
const submission = bundle[id] || {};
|
|
2723
|
+
const perOpts = { ...runOpts, session_id: sessionId };
|
|
2724
|
+
if (submission.precondition_checks) perOpts.precondition_checks = submission.precondition_checks;
|
|
2725
|
+
let result;
|
|
2726
|
+
try { result = runner.run(id, directiveId, submission, perOpts); }
|
|
2727
|
+
catch (e) { result = { ok: false, error: e.message, playbook_id: id }; }
|
|
2728
|
+
results.push(result);
|
|
2729
|
+
if (!result || result.ok === false) {
|
|
2730
|
+
fail = true;
|
|
2731
|
+
failReasons.push(`${id}: blocked (${result?.reason || result?.error || "unknown"})`);
|
|
2732
|
+
continue;
|
|
2733
|
+
}
|
|
2734
|
+
const cls = result.phases?.detect?.classification;
|
|
2735
|
+
const rwepAdj = result.phases?.analyze?.rwep?.adjusted ?? 0;
|
|
2736
|
+
const cap = maxRwep !== null
|
|
2737
|
+
? maxRwep
|
|
2738
|
+
: (result.phases?.analyze?.rwep?.threshold?.escalate ?? 90);
|
|
2739
|
+
const clockStarted = (result.phases?.close?.notification_actions || [])
|
|
2740
|
+
.some(n => n && n.clock_started_at != null);
|
|
2741
|
+
if (cls === "detected") {
|
|
2742
|
+
fail = true;
|
|
2743
|
+
failReasons.push(`${id}: classification=detected`);
|
|
2744
|
+
}
|
|
2745
|
+
if (cls !== "not_detected" && cls !== "clean" && rwepAdj >= cap) {
|
|
2746
|
+
fail = true;
|
|
2747
|
+
failReasons.push(`${id}: rwep=${rwepAdj} >= cap=${cap} (classification=${cls})`);
|
|
2748
|
+
}
|
|
2749
|
+
if (blockOnClock && clockStarted) {
|
|
2750
|
+
fail = true;
|
|
2751
|
+
failReasons.push(`${id}: jurisdiction clock started`);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
|
|
2756
|
+
const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
|
|
2757
|
+
|
|
2758
|
+
emit({
|
|
2759
|
+
verb: "ci",
|
|
2760
|
+
session_id: sessionId,
|
|
2761
|
+
playbooks_run: ids,
|
|
2762
|
+
summary: {
|
|
2763
|
+
total: results.length,
|
|
2764
|
+
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
2765
|
+
inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
|
|
2766
|
+
not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
|
|
2767
|
+
blocked: results.filter(r => r && r.ok === false).length,
|
|
2768
|
+
max_rwep_observed: maxRwepObserved,
|
|
2769
|
+
jurisdiction_clocks_started: results
|
|
2770
|
+
.flatMap(r => r.phases?.close?.notification_actions || [])
|
|
2771
|
+
.filter(n => n && n.clock_started_at != null).length,
|
|
2772
|
+
verdict: fail ? "FAIL" : "PASS",
|
|
2773
|
+
fail_reasons: failReasons,
|
|
2774
|
+
},
|
|
2775
|
+
results,
|
|
2776
|
+
}, pretty);
|
|
2777
|
+
if (fail) {
|
|
2778
|
+
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
2779
|
+
process.exit(2);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
1308
2783
|
if (require.main === module) main();
|
|
1309
2784
|
|
|
1310
2785
|
module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
|