@blamejs/exceptd-skills 0.10.2 → 0.11.0
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 +114 -0
- package/bin/exceptd.js +1874 -143
- package/data/_indexes/_meta.json +2 -2
- package/lib/playbook-runner.js +222 -9
- package/lib/prefetch.js +9 -1
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/index.js +98 -8
- package/package.json +2 -1
- package/sbom.cdx.json +6 -6
- package/sources/README.md +170 -0
- package/sources/validators/atlas-validator.js +158 -0
- package/sources/validators/cve-validator.js +277 -0
- package/sources/validators/index.js +86 -0
- package/sources/validators/rfc-validator.js +165 -0
- package/sources/validators/version-pin-validator.js +144 -0
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
|
|
@@ -197,7 +276,7 @@ Project rules: ${PKG_ROOT}/AGENTS.md
|
|
|
197
276
|
function main() {
|
|
198
277
|
const argv = process.argv.slice(2);
|
|
199
278
|
if (argv.length === 0) {
|
|
200
|
-
|
|
279
|
+
printWelcome();
|
|
201
280
|
process.exit(0);
|
|
202
281
|
}
|
|
203
282
|
const cmd = argv[0];
|
|
@@ -219,13 +298,26 @@ function main() {
|
|
|
219
298
|
// Seven-phase playbook verbs run in-process — they emit JSON to stdout
|
|
220
299
|
// rather than dispatch to a script.
|
|
221
300
|
if (PLAYBOOK_VERBS.has(cmd)) {
|
|
301
|
+
// One-time deprecation banner per process when a legacy verb is invoked.
|
|
302
|
+
if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
|
|
303
|
+
process.stderr.write(
|
|
304
|
+
`[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (v0.11.0). ` +
|
|
305
|
+
`Legacy verbs remain functional through this release; they will be removed in v0.12. ` +
|
|
306
|
+
`Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
|
|
307
|
+
);
|
|
308
|
+
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
309
|
+
}
|
|
222
310
|
dispatchPlaybook(cmd, rest);
|
|
223
311
|
return;
|
|
224
312
|
}
|
|
225
313
|
|
|
226
314
|
const resolver = COMMANDS[cmd];
|
|
227
315
|
if (typeof resolver !== "function") {
|
|
228
|
-
|
|
316
|
+
// Emit a structured JSON error matching the seven-phase verbs so operators
|
|
317
|
+
// piping through `jq` get one consistent shape across the CLI surface.
|
|
318
|
+
// Plain-text "unknown command" still reaches stderr for human readers.
|
|
319
|
+
const err = { ok: false, error: `unknown command "${cmd}"`, hint: "Run `exceptd help` for the list of verbs.", verb: cmd };
|
|
320
|
+
process.stderr.write(JSON.stringify(err) + "\n");
|
|
229
321
|
process.exit(2);
|
|
230
322
|
}
|
|
231
323
|
|
|
@@ -335,8 +427,10 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
335
427
|
}
|
|
336
428
|
|
|
337
429
|
const args = parseArgs(argv, {
|
|
338
|
-
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
339
|
-
|
|
430
|
+
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
431
|
+
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
432
|
+
"force-overwrite", "no-stream", "block-on-jurisdiction-clock"],
|
|
433
|
+
multi: ["playbook", "format"],
|
|
340
434
|
});
|
|
341
435
|
const pretty = !!args.pretty;
|
|
342
436
|
const runOpts = {
|
|
@@ -344,8 +438,37 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
344
438
|
forceStale: !!args["force-stale"],
|
|
345
439
|
};
|
|
346
440
|
if (args["session-id"]) runOpts.session_id = args["session-id"];
|
|
347
|
-
if (args["
|
|
348
|
-
if (args
|
|
441
|
+
if (args["attestation-root"]) runOpts.attestationRoot = args["attestation-root"];
|
|
442
|
+
if (args["session-key"]) {
|
|
443
|
+
// Bug #33: validate that --session-key is hex. Previously any string was
|
|
444
|
+
// silently accepted; HMAC signing then either failed silently or produced
|
|
445
|
+
// an unverifiable signature.
|
|
446
|
+
if (!/^[0-9a-fA-F]+$/.test(args["session-key"])) {
|
|
447
|
+
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);
|
|
448
|
+
}
|
|
449
|
+
if (args["session-key"].length < 16) {
|
|
450
|
+
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);
|
|
451
|
+
}
|
|
452
|
+
runOpts.session_key = args["session-key"];
|
|
453
|
+
}
|
|
454
|
+
if (args.mode) {
|
|
455
|
+
// Bug #32: validate --mode against the accepted set. Previously
|
|
456
|
+
// `--mode garbage` was silently accepted.
|
|
457
|
+
const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
|
|
458
|
+
if (!VALID_MODES.includes(args.mode)) {
|
|
459
|
+
return emitError(`run: --mode "${args.mode}" not in accepted set ${JSON.stringify(VALID_MODES)}.`, { provided: args.mode }, pretty);
|
|
460
|
+
}
|
|
461
|
+
runOpts.mode = args.mode;
|
|
462
|
+
}
|
|
463
|
+
// Multi-operator teams need attestations bound to a specific human or
|
|
464
|
+
// service identity. --operator <name> persists into the attestation file
|
|
465
|
+
// for audit-trail accountability. Free-form string; no validation.
|
|
466
|
+
if (args.operator) runOpts.operator = args.operator;
|
|
467
|
+
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
468
|
+
// govern. Captured in attestation; downstream tooling can check whether
|
|
469
|
+
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
470
|
+
// and wait for ack — this is how the ack gets recorded.
|
|
471
|
+
if (args.ack) runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
|
|
349
472
|
|
|
350
473
|
let runner;
|
|
351
474
|
try {
|
|
@@ -365,6 +488,16 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
365
488
|
case "ingest": return cmdIngest(runner, args, runOpts, pretty);
|
|
366
489
|
case "reattest": return cmdReattest(runner, args, runOpts, pretty);
|
|
367
490
|
case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
|
|
491
|
+
case "attest": return cmdAttest(runner, args, runOpts, pretty);
|
|
492
|
+
case "brief": return cmdBrief(runner, args, runOpts, pretty);
|
|
493
|
+
case "run-all": return cmdRunAll(runner, args, runOpts, pretty);
|
|
494
|
+
case "verify-attestation": return cmdVerifyAttestation(runner, args, runOpts, pretty);
|
|
495
|
+
case "lint": return cmdLint(runner, args, runOpts, pretty);
|
|
496
|
+
case "discover": return cmdDiscover(runner, args, runOpts, pretty);
|
|
497
|
+
case "doctor": return cmdDoctor(runner, args, runOpts, pretty);
|
|
498
|
+
case "ai-run": return cmdAiRun(runner, args, runOpts, pretty);
|
|
499
|
+
case "ask": return cmdAsk(runner, args, runOpts, pretty);
|
|
500
|
+
case "ci": return cmdCi(runner, args, runOpts, pretty);
|
|
368
501
|
}
|
|
369
502
|
} catch (e) {
|
|
370
503
|
emitError(e.message, { verb: cmd }, pretty);
|
|
@@ -429,11 +562,27 @@ Flags:
|
|
|
429
562
|
{ artifacts, signal_overrides, signals, precondition_checks }
|
|
430
563
|
Multi-playbook shape:
|
|
431
564
|
{ "<playbook_id>": { artifacts, ... }, ... }
|
|
565
|
+
--evidence-dir <dir> Read <playbook-id>.json files from a directory and
|
|
566
|
+
merge into the multi-run bundle. Cron-friendly.
|
|
432
567
|
--vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
|
|
433
568
|
not_affected | resolved | false_positive (CycloneDX)
|
|
434
569
|
or not_affected | fixed (OpenVEX) drop out of
|
|
435
570
|
analyze.matched_cves. The disposition is preserved
|
|
436
571
|
under analyze.vex.dropped_cves.
|
|
572
|
+
--format <fmt> ... Emit the close.evidence_package bundle in additional
|
|
573
|
+
formats. Repeatable. Supported: csaf-2.0 | sarif |
|
|
574
|
+
openvex | markdown. CSAF is always primary; extras
|
|
575
|
+
populate close.evidence_package.bundles_by_format.
|
|
576
|
+
--explain Dry-run: emit preconditions, required artifacts,
|
|
577
|
+
recognized signal keys, and a submission skeleton.
|
|
578
|
+
Does not run detect/analyze/validate/close.
|
|
579
|
+
--signal-list Emit only the signal_overrides keys the detect phase
|
|
580
|
+
recognizes (lighter than --explain).
|
|
581
|
+
--operator <name> Bind the attestation to a specific human/service
|
|
582
|
+
identity. Persisted under attestation.operator.
|
|
583
|
+
--ack Mark explicit operator consent to the jurisdiction
|
|
584
|
+
obligations surfaced by govern. Persisted under
|
|
585
|
+
attestation.operator_consent.
|
|
437
586
|
--diff-from-latest Compare evidence_hash against the most recent prior
|
|
438
587
|
attestation for the same playbook in
|
|
439
588
|
.exceptd/attestations/. Emits status: unchanged | drifted.
|
|
@@ -474,10 +623,273 @@ Args / flags:
|
|
|
474
623
|
|
|
475
624
|
Lists every attestation under .exceptd/attestations/<session_id>/, sorted
|
|
476
625
|
newest-first, with truncated evidence_hash + capture timestamp + file path.`,
|
|
626
|
+
attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
|
|
627
|
+
|
|
628
|
+
Subverbs:
|
|
629
|
+
attest show <sid> Emit the full (unredacted) attestation.
|
|
630
|
+
attest export <sid> Emit redacted JSON suitable for audit submission.
|
|
631
|
+
Strips raw artifact values; preserves evidence_hash,
|
|
632
|
+
signature, classification, RWEP, remediation choice.
|
|
633
|
+
--format csaf wraps the export in a CSAF envelope.
|
|
634
|
+
attest verify <sid> Verify .sig sidecar against keys/public.pem.
|
|
635
|
+
Reports tamper status per attestation file.
|
|
636
|
+
|
|
637
|
+
All subverbs honor --pretty for indented JSON output.`,
|
|
638
|
+
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
639
|
+
|
|
640
|
+
Replaces: scan + dispatch + recommend.
|
|
641
|
+
|
|
642
|
+
Sniffs the cwd (.git/, package.json, pyproject.toml, requirements.txt,
|
|
643
|
+
Cargo.toml, go.mod, Dockerfile, docker-compose.yml, *.tf, k8s/, .env) and
|
|
644
|
+
on Linux reads /etc/os-release to detect host distro. Emits a list of
|
|
645
|
+
recommended exceptd playbooks tailored to what was found.
|
|
646
|
+
|
|
647
|
+
Flags:
|
|
648
|
+
--scan-only Also include legacy \`scan\` output under legacy_scan.
|
|
649
|
+
--json Emit JSON (default is human-readable text).
|
|
650
|
+
--pretty Indented JSON output (implies --json).
|
|
651
|
+
|
|
652
|
+
Output: context + recommended_playbooks[] + next_steps[].`,
|
|
653
|
+
doctor: `doctor — one-shot health check (v0.11.0).
|
|
654
|
+
|
|
655
|
+
Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
|
|
656
|
+
|
|
657
|
+
Subchecks:
|
|
658
|
+
--signatures Ed25519 signature verification across all skills.
|
|
659
|
+
--currency Skill currency report (last_threat_review).
|
|
660
|
+
--cves CVE catalog validation (offline view).
|
|
661
|
+
--rfcs RFC catalog validation (offline view).
|
|
662
|
+
(no flag) All four, plus signing-status (private key presence).
|
|
663
|
+
|
|
664
|
+
Flags:
|
|
665
|
+
--json Emit JSON (default is human-readable text).
|
|
666
|
+
--pretty Indented JSON output (implies --json).
|
|
667
|
+
|
|
668
|
+
Output: checks{} per subcheck + summary{all_green, issues_count}.`,
|
|
669
|
+
"ai-run": `ai-run <playbook> — streaming JSONL contract for AI-driven runs (v0.11.0).
|
|
670
|
+
|
|
671
|
+
Emits one JSON event per line as the seven phases progress, and reads
|
|
672
|
+
evidence events back on stdin. Single pipe instead of brief → look → run.
|
|
673
|
+
|
|
674
|
+
Flags:
|
|
675
|
+
<playbook> Required positional.
|
|
676
|
+
--directive <id> Specific directive (default: first one).
|
|
677
|
+
--no-stream Single-shot mode: emit all phases as one JSON doc
|
|
678
|
+
without reading stdin (uses runner.run directly).
|
|
679
|
+
--pretty Indented JSON output (single-shot only).
|
|
680
|
+
|
|
681
|
+
Stdin event grammar (one JSON object per line):
|
|
682
|
+
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
683
|
+
|
|
684
|
+
Emits phases: govern → direct → look → await_evidence → detect → analyze
|
|
685
|
+
→ validate → close, then {"event":"done","ok":true,"session_id":"..."}.
|
|
686
|
+
Errors emit {"event":"error","reason":"..."} and exit non-zero.`,
|
|
687
|
+
ask: `ask "<plain-English question>" — keyword routing to playbooks (v0.11.0).
|
|
688
|
+
|
|
689
|
+
Tokenises the question (words > 3 chars), scores every playbook by overlap
|
|
690
|
+
against domain.name + domain.attack_class + the first sentence of
|
|
691
|
+
phases.direct.threat_context, returns the top 5 matches with a confidence
|
|
692
|
+
score.
|
|
693
|
+
|
|
694
|
+
Args / flags:
|
|
695
|
+
"<question>" Plain-English question. Wrap in quotes.
|
|
696
|
+
--pretty Indented JSON output.
|
|
697
|
+
|
|
698
|
+
Output: { verb, question, routed_to:[ids], confidence, next_step,
|
|
699
|
+
full_match_list }. Empty match list when no token overlap — surfaces a
|
|
700
|
+
hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.`,
|
|
701
|
+
ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
|
|
702
|
+
|
|
703
|
+
Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
|
|
704
|
+
exit-code contract designed for one-line .github/workflows entries.
|
|
705
|
+
|
|
706
|
+
Flags:
|
|
707
|
+
--all Run every playbook.
|
|
708
|
+
--scope <type> Filter: system | code | service | cross-cutting.
|
|
709
|
+
(no flag) Auto-detect scopes from cwd (same logic as run).
|
|
710
|
+
--evidence <file> Submission bundle (multi-playbook shape).
|
|
711
|
+
--evidence-dir <dir> Read <playbook-id>.json files from a directory.
|
|
712
|
+
--max-rwep <int> Override RWEP escalate threshold (default: per-playbook).
|
|
713
|
+
--block-on-jurisdiction-clock
|
|
714
|
+
Fail when any close.notification_actions started a
|
|
715
|
+
regulatory clock (GDPR 72h, HIPAA breach, etc.).
|
|
716
|
+
--pretty Indented JSON output.
|
|
717
|
+
|
|
718
|
+
Exit codes: 0 PASS, 2 FAIL (detected | rwep ≥ cap | clock started w/ block flag).
|
|
719
|
+
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
720
|
+
max_rwep_observed, jurisdiction_clocks_started, verdict}, results[].`,
|
|
477
721
|
};
|
|
478
722
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
479
723
|
}
|
|
480
724
|
|
|
725
|
+
/**
|
|
726
|
+
* `brief` — collapses plan + govern + direct + look into one informational
|
|
727
|
+
* document. Phases 1-3 of the seven-phase contract are entirely informational
|
|
728
|
+
* (no state mutation), so the AI reads ONE document instead of three CLI
|
|
729
|
+
* round-trips.
|
|
730
|
+
*
|
|
731
|
+
* Modes:
|
|
732
|
+
* brief <playbook> → one playbook, all three info phases unified
|
|
733
|
+
* brief --all → every playbook (replaces `plan`)
|
|
734
|
+
* brief <playbook> --phase <name>
|
|
735
|
+
* → emit only the named phase (compat with
|
|
736
|
+
* legacy `govern`/`direct`/`look` callers)
|
|
737
|
+
*/
|
|
738
|
+
/**
|
|
739
|
+
* `lint <playbook> <evidence-file>` — pre-flight check the submission shape
|
|
740
|
+
* against the playbook's expected indicators / preconditions / artifacts
|
|
741
|
+
* WITHOUT executing detect/analyze/validate/close. Lets the AI iterate on
|
|
742
|
+
* its evidence JSON before going through phases 4-7. Returns a categorized
|
|
743
|
+
* list: ok / missing_required / unknown_keys / type_mismatch / suggestions.
|
|
744
|
+
*/
|
|
745
|
+
function cmdLint(runner, args, runOpts, pretty) {
|
|
746
|
+
const playbookId = args._[0];
|
|
747
|
+
const evidencePath = args._[1] || args.evidence;
|
|
748
|
+
if (!playbookId || !evidencePath) {
|
|
749
|
+
return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
|
|
750
|
+
}
|
|
751
|
+
let pb;
|
|
752
|
+
try { pb = runner.loadPlaybook(playbookId); }
|
|
753
|
+
catch (e) { return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty); }
|
|
754
|
+
|
|
755
|
+
let submission;
|
|
756
|
+
try { submission = readEvidence(evidencePath); }
|
|
757
|
+
catch (e) { return emitError(`lint: failed to read evidence: ${e.message}`, { evidence: evidencePath }, pretty); }
|
|
758
|
+
|
|
759
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
760
|
+
const resolved = runner._resolvedPhase;
|
|
761
|
+
const lookPhase = pb.phases?.look || {};
|
|
762
|
+
const detectPhase = pb.phases?.detect || {};
|
|
763
|
+
|
|
764
|
+
const requiredArtifacts = (lookPhase.artifacts || []).filter(a => a.required).map(a => a.id);
|
|
765
|
+
const knownArtifacts = new Set((lookPhase.artifacts || []).map(a => a.id));
|
|
766
|
+
const knownIndicators = new Set((detectPhase.indicators || []).map(i => i.id));
|
|
767
|
+
const knownPreconditions = new Set((pb._meta?.preconditions || []).map(p => p.id));
|
|
768
|
+
|
|
769
|
+
// Support both flat (observations) and nested (artifacts/signal_overrides) shapes.
|
|
770
|
+
const flat = submission.observations || null;
|
|
771
|
+
const artifactsKey = flat ? flat : (submission.artifacts || {});
|
|
772
|
+
const signalsKey = flat ? flat : (submission.signal_overrides || {});
|
|
773
|
+
|
|
774
|
+
const missingRequired = requiredArtifacts.filter(id => {
|
|
775
|
+
const a = artifactsKey[id];
|
|
776
|
+
return !a || (flat ? !a.captured : !a.captured);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const unknownArtifactKeys = Object.keys(submission.artifacts || {})
|
|
780
|
+
.filter(k => !knownArtifacts.has(k));
|
|
781
|
+
const unknownSignalKeys = Object.keys(submission.signal_overrides || {})
|
|
782
|
+
.filter(k => !knownIndicators.has(k));
|
|
783
|
+
const unknownObservationKeys = flat
|
|
784
|
+
? Object.keys(flat).filter(k => !knownArtifacts.has(k) && !knownIndicators.has(k) && !knownPreconditions.has(k))
|
|
785
|
+
: [];
|
|
786
|
+
|
|
787
|
+
const unsuppliedPreconditions = [...knownPreconditions].filter(
|
|
788
|
+
p => !((submission.precondition_checks || {}).hasOwnProperty(p) || (flat || {}).hasOwnProperty(p))
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
const issues = [];
|
|
792
|
+
for (const id of missingRequired) {
|
|
793
|
+
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).` });
|
|
794
|
+
}
|
|
795
|
+
for (const k of unknownArtifactKeys) {
|
|
796
|
+
issues.push({ severity: "warn", kind: "unknown_artifact_key", key: k, hint: `Not in playbook ${playbookId} look.artifacts[]. Recognized: ${[...knownArtifacts].slice(0, 10).join(", ")}…` });
|
|
797
|
+
}
|
|
798
|
+
for (const k of unknownSignalKeys) {
|
|
799
|
+
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.` });
|
|
800
|
+
}
|
|
801
|
+
for (const p of unsuppliedPreconditions) {
|
|
802
|
+
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).` });
|
|
803
|
+
}
|
|
804
|
+
for (const k of unknownObservationKeys) {
|
|
805
|
+
issues.push({ severity: "warn", kind: "unknown_observation_key", key: k });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const ok = issues.every(i => i.severity !== "error");
|
|
809
|
+
emit({
|
|
810
|
+
verb: "lint",
|
|
811
|
+
ok,
|
|
812
|
+
playbook_id: playbookId,
|
|
813
|
+
directive_id: directiveId,
|
|
814
|
+
submission_shape: flat ? "flat (v0.11.0)" : "nested (v0.10.x)",
|
|
815
|
+
summary: {
|
|
816
|
+
errors: issues.filter(i => i.severity === "error").length,
|
|
817
|
+
warnings: issues.filter(i => i.severity === "warn").length,
|
|
818
|
+
info: issues.filter(i => i.severity === "info").length,
|
|
819
|
+
},
|
|
820
|
+
issues,
|
|
821
|
+
}, pretty);
|
|
822
|
+
if (!ok) process.exitCode = 1;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function cmdBrief(runner, args, runOpts, pretty) {
|
|
826
|
+
const playbookId = args._[0];
|
|
827
|
+
const onlyPhase = args.phase || null;
|
|
828
|
+
|
|
829
|
+
if (!playbookId || args.all) {
|
|
830
|
+
// Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
|
|
831
|
+
return cmdPlan(runner, args, runOpts, pretty);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const pb = runner.loadPlaybook(playbookId);
|
|
835
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
836
|
+
|
|
837
|
+
const govern = runner.govern(playbookId, directiveId, runOpts);
|
|
838
|
+
const direct = runner.direct(playbookId, directiveId);
|
|
839
|
+
const look = runner.look(playbookId, directiveId, runOpts);
|
|
840
|
+
|
|
841
|
+
// If --phase was passed, emit only that phase to ease legacy migration.
|
|
842
|
+
if (onlyPhase === "govern") return emit(govern, pretty);
|
|
843
|
+
if (onlyPhase === "direct") return emit(direct, pretty);
|
|
844
|
+
if (onlyPhase === "look") return emit(look, pretty);
|
|
845
|
+
|
|
846
|
+
emit({
|
|
847
|
+
verb: "brief",
|
|
848
|
+
playbook_id: playbookId,
|
|
849
|
+
directive_id: directiveId,
|
|
850
|
+
scope: pb._meta?.scope || null,
|
|
851
|
+
threat_currency_score: pb._meta?.threat_currency_score,
|
|
852
|
+
|
|
853
|
+
// From govern phase:
|
|
854
|
+
jurisdiction_obligations: govern.jurisdiction_obligations,
|
|
855
|
+
theater_fingerprints: govern.theater_fingerprints,
|
|
856
|
+
framework_context: govern.framework_context,
|
|
857
|
+
skill_preload: govern.skill_preload,
|
|
858
|
+
|
|
859
|
+
// From direct phase:
|
|
860
|
+
threat_context: direct.threat_context,
|
|
861
|
+
rwep_threshold: direct.rwep_threshold,
|
|
862
|
+
framework_lag_declaration: direct.framework_lag_declaration,
|
|
863
|
+
skill_chain: direct.skill_chain,
|
|
864
|
+
token_budget: direct.token_budget,
|
|
865
|
+
|
|
866
|
+
// From look phase:
|
|
867
|
+
preconditions: look.preconditions,
|
|
868
|
+
precondition_submission_shape: look.precondition_submission_shape,
|
|
869
|
+
artifacts: look.artifacts,
|
|
870
|
+
collection_scope: look.collection_scope,
|
|
871
|
+
environment_assumptions: look.environment_assumptions,
|
|
872
|
+
fallback_if_unavailable: look.fallback_if_unavailable,
|
|
873
|
+
|
|
874
|
+
// Forward references — what the AI will see during run:
|
|
875
|
+
detect_indicators_preview: (pb.phases?.detect?.indicators || []).map(i => ({
|
|
876
|
+
id: i.id, type: i.type, confidence: i.confidence, deterministic: !!i.deterministic
|
|
877
|
+
})),
|
|
878
|
+
}, pretty);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** `run-all` alias for `run --all`. */
|
|
882
|
+
function cmdRunAll(runner, args, runOpts, pretty) {
|
|
883
|
+
args.all = true;
|
|
884
|
+
return cmdRun(runner, args, runOpts, pretty);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** `verify-attestation <sid>` alias for `attest verify <sid>`. */
|
|
888
|
+
function cmdVerifyAttestation(runner, args, runOpts, pretty) {
|
|
889
|
+
args._ = ["verify", ...(args._ || [])];
|
|
890
|
+
return cmdAttest(runner, args, runOpts, pretty);
|
|
891
|
+
}
|
|
892
|
+
|
|
481
893
|
function cmdPlan(runner, args, runOpts, pretty) {
|
|
482
894
|
let playbookIds = args.playbook
|
|
483
895
|
? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
|
|
@@ -498,13 +910,27 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
498
910
|
Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
|
|
499
911
|
);
|
|
500
912
|
}
|
|
501
|
-
// --directives expands each playbook entry with
|
|
502
|
-
// applies_to
|
|
503
|
-
//
|
|
913
|
+
// --directives expands each playbook entry with directive id + title +
|
|
914
|
+
// applies_to + description. v0.10.3-aware fallback: pull description from
|
|
915
|
+
// (a) explicit d.description, (b) directive override threat_context,
|
|
916
|
+
// (c) playbook-level direct.threat_context first sentence, (d) playbook
|
|
917
|
+
// domain.name. Operators need operator-facing prose, not just an ID + enum.
|
|
504
918
|
if (args.directives) {
|
|
505
919
|
for (const pb of plan.playbooks) {
|
|
506
920
|
const full = runner.loadPlaybook(pb.id);
|
|
507
|
-
|
|
921
|
+
const baseDirect = full.phases?.direct || {};
|
|
922
|
+
pb.directives = full.directives.map(d => {
|
|
923
|
+
const overrideDirect = d.phase_overrides?.direct || {};
|
|
924
|
+
const threatContext = overrideDirect.threat_context || baseDirect.threat_context || null;
|
|
925
|
+
const firstSentence = threatContext ? (threatContext.split(/(?<=[.!?])\s+/)[0] || "").slice(0, 240) : null;
|
|
926
|
+
return {
|
|
927
|
+
id: d.id,
|
|
928
|
+
title: d.title,
|
|
929
|
+
description: d.description || firstSentence || full.domain?.name || null,
|
|
930
|
+
applies_to: d.applies_to,
|
|
931
|
+
threat_context_preview: firstSentence,
|
|
932
|
+
};
|
|
933
|
+
});
|
|
508
934
|
}
|
|
509
935
|
}
|
|
510
936
|
emit(plan, pretty);
|
|
@@ -606,6 +1032,53 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
606
1032
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
607
1033
|
if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
|
|
608
1034
|
|
|
1035
|
+
// --explain: dry-run that emits the preconditions + artifacts + indicators
|
|
1036
|
+
// + signal keys the agent would need to supply, WITHOUT running detect/
|
|
1037
|
+
// analyze/validate/close. Lets operators preview before assembling evidence.
|
|
1038
|
+
if (args.explain) {
|
|
1039
|
+
const lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
1040
|
+
const detectPhase = runner.loadPlaybook(playbookId).phases?.detect || {};
|
|
1041
|
+
const detectResolved = runner._resolvedPhase ? runner._resolvedPhase(pb, directiveId, "detect") : detectPhase;
|
|
1042
|
+
emit({
|
|
1043
|
+
verb: "run",
|
|
1044
|
+
mode: "explain",
|
|
1045
|
+
playbook_id: playbookId,
|
|
1046
|
+
directive_id: directiveId,
|
|
1047
|
+
scope: pb._meta?.scope || null,
|
|
1048
|
+
preconditions: lookPhase.preconditions,
|
|
1049
|
+
precondition_submission_shape: lookPhase.precondition_submission_shape,
|
|
1050
|
+
artifacts_required: lookPhase.artifacts.filter(a => a.required).map(a => ({ id: a.id, type: a.type, source: a.source })),
|
|
1051
|
+
artifacts_optional: lookPhase.artifacts.filter(a => !a.required).map(a => ({ id: a.id, type: a.type, source: a.source, fallback: lookPhase.fallback_if_unavailable.find(f => f.artifact_id === a.id) })),
|
|
1052
|
+
signal_keys: (detectResolved.indicators || []).map(i => ({ id: i.id, type: i.type, deterministic: !!i.deterministic, confidence: i.confidence })),
|
|
1053
|
+
detect_classification_override: { hint: "submit signals.detection_classification = 'detected' | 'inconclusive' | 'not_detected' | 'clean' to override engine-computed classification.", valid_values: ["detected", "inconclusive", "not_detected", "clean"] },
|
|
1054
|
+
submission_skeleton: {
|
|
1055
|
+
artifacts: Object.fromEntries(lookPhase.artifacts.map(a => [a.id, { value: "<your captured output>", captured: true }])),
|
|
1056
|
+
signal_overrides: Object.fromEntries((detectResolved.indicators || []).map(i => [i.id, "hit | miss | inconclusive"])),
|
|
1057
|
+
signals: { detection_classification: "<one of: detected|inconclusive|not_detected|clean>", theater_verdict: "<clear | theater | pending_agent_run>" },
|
|
1058
|
+
precondition_checks: Object.fromEntries(lookPhase.preconditions.map(p => [p.id, true])),
|
|
1059
|
+
}
|
|
1060
|
+
}, pretty);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// --signal-list: enumerate every signal_overrides key the detect phase
|
|
1065
|
+
// recognizes. Lighter than --explain.
|
|
1066
|
+
if (args["signal-list"]) {
|
|
1067
|
+
const detectResolved = runner._resolvedPhase
|
|
1068
|
+
? runner._resolvedPhase(pb, directiveId, "detect")
|
|
1069
|
+
: pb.phases?.detect;
|
|
1070
|
+
emit({
|
|
1071
|
+
verb: "run",
|
|
1072
|
+
mode: "signal-list",
|
|
1073
|
+
playbook_id: playbookId,
|
|
1074
|
+
directive_id: directiveId,
|
|
1075
|
+
signal_overrides_keys: (detectResolved?.indicators || []).map(i => i.id),
|
|
1076
|
+
signal_value_grammar: "hit | miss | inconclusive",
|
|
1077
|
+
detection_classification_override_keys: ["detected", "inconclusive", "not_detected", "clean"],
|
|
1078
|
+
}, pretty);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
609
1082
|
let submission = {};
|
|
610
1083
|
if (args.evidence) {
|
|
611
1084
|
try {
|
|
@@ -621,6 +1094,15 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
621
1094
|
runOpts.precondition_checks = submission.precondition_checks;
|
|
622
1095
|
}
|
|
623
1096
|
|
|
1097
|
+
// --format <fmt>: override the playbook's declared evidence_package.bundle_format.
|
|
1098
|
+
// Supports csaf-2.0 | sarif | openvex | markdown. Multiple --format flags
|
|
1099
|
+
// produce multiple bundles in the close response under bundles_by_format.
|
|
1100
|
+
if (args.format) {
|
|
1101
|
+
const formats = Array.isArray(args.format) ? args.format : [args.format];
|
|
1102
|
+
submission.signals = submission.signals || {};
|
|
1103
|
+
submission.signals._bundle_formats = formats;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
624
1106
|
// --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
|
|
625
1107
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
626
1108
|
if (args.vex) {
|
|
@@ -638,22 +1120,41 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
638
1120
|
|
|
639
1121
|
// Persist attestation for reattest cycles when the run succeeded.
|
|
640
1122
|
if (result && result.ok && result.session_id) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1123
|
+
const persistResult = persistAttestation({
|
|
1124
|
+
sessionId: result.session_id,
|
|
1125
|
+
playbookId: result.playbook_id,
|
|
1126
|
+
directiveId: result.directive_id,
|
|
1127
|
+
evidenceHash: result.evidence_hash,
|
|
1128
|
+
operator: runOpts.operator,
|
|
1129
|
+
operatorConsent: runOpts.operator_consent,
|
|
1130
|
+
submission,
|
|
1131
|
+
runOpts,
|
|
1132
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
1133
|
+
filename: "attestation.json",
|
|
1134
|
+
});
|
|
1135
|
+
if (!persistResult.ok) {
|
|
1136
|
+
// Session-id collision without --force-overwrite. Refuse, surface the
|
|
1137
|
+
// existing path so the operator can decide, and emit JSON to stderr
|
|
1138
|
+
// matching the unified error shape. Exit non-zero — a silent overwrite
|
|
1139
|
+
// is a tamper-evidence violation.
|
|
1140
|
+
const err = {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
error: persistResult.error,
|
|
1143
|
+
existing_attestation: persistResult.existingPath,
|
|
1144
|
+
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
1145
|
+
verb: "run",
|
|
1146
|
+
};
|
|
1147
|
+
process.stderr.write(JSON.stringify(err) + "\n");
|
|
1148
|
+
process.exit(3);
|
|
1149
|
+
}
|
|
1150
|
+
if (persistResult.prior_session_id) {
|
|
1151
|
+
// Force-overwrite happened — surface the prior_session_id in the
|
|
1152
|
+
// returned result so the operator/AI can see what the new attestation
|
|
1153
|
+
// replaced and link back via the prior_session_id field persisted on
|
|
1154
|
+
// disk.
|
|
1155
|
+
result.prior_session_id = persistResult.prior_session_id;
|
|
1156
|
+
result.overwrote_at = persistResult.overwrote_at;
|
|
1157
|
+
}
|
|
657
1158
|
}
|
|
658
1159
|
|
|
659
1160
|
if (result && result.ok === false) {
|
|
@@ -746,6 +1247,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
746
1247
|
return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
|
|
747
1248
|
}
|
|
748
1249
|
}
|
|
1250
|
+
// --evidence-dir <dir>: each <playbook-id>.json under the directory is read
|
|
1251
|
+
// as that playbook's submission. Lets operators wire up one cron job that
|
|
1252
|
+
// collects per-playbook evidence into a directory, then runs the whole
|
|
1253
|
+
// contract in one pass.
|
|
1254
|
+
if (args["evidence-dir"]) {
|
|
1255
|
+
const dir = args["evidence-dir"];
|
|
1256
|
+
if (!fs.existsSync(dir)) {
|
|
1257
|
+
return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
1258
|
+
}
|
|
1259
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
1260
|
+
const pbId = f.replace(/\.json$/, "");
|
|
1261
|
+
try {
|
|
1262
|
+
bundle[pbId] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
1263
|
+
} catch (e) {
|
|
1264
|
+
return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
749
1268
|
|
|
750
1269
|
const results = [];
|
|
751
1270
|
for (const id of ids) {
|
|
@@ -763,22 +1282,26 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
763
1282
|
|
|
764
1283
|
// Persist per-playbook attestation under the shared session.
|
|
765
1284
|
if (result && result.ok) {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1285
|
+
const persisted = persistAttestation({
|
|
1286
|
+
sessionId,
|
|
1287
|
+
playbookId: id,
|
|
1288
|
+
directiveId,
|
|
1289
|
+
evidenceHash: result.evidence_hash,
|
|
1290
|
+
operator: perRunOpts.operator,
|
|
1291
|
+
operatorConsent: perRunOpts.operator_consent,
|
|
1292
|
+
submission,
|
|
1293
|
+
runOpts: perRunOpts,
|
|
1294
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
1295
|
+
filename: `${id}.json`,
|
|
1296
|
+
});
|
|
1297
|
+
if (!persisted.ok) {
|
|
1298
|
+
// Multi-run collision: don't abort the whole bundle; surface in the
|
|
1299
|
+
// per-playbook result so the operator can see exactly which
|
|
1300
|
+
// playbook's attestation refused to overwrite.
|
|
1301
|
+
result.attestation_persist = { ok: false, error: persisted.error };
|
|
1302
|
+
} else if (persisted.prior_session_id) {
|
|
1303
|
+
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
1304
|
+
}
|
|
782
1305
|
}
|
|
783
1306
|
results.push(result);
|
|
784
1307
|
}
|
|
@@ -834,7 +1357,7 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
834
1357
|
|
|
835
1358
|
if (result && result.ok && result.session_id) {
|
|
836
1359
|
try {
|
|
837
|
-
const dir = path.join(
|
|
1360
|
+
const dir = path.join(resolveAttestationRoot(runOpts), result.session_id);
|
|
838
1361
|
fs.mkdirSync(dir, { recursive: true });
|
|
839
1362
|
fs.writeFileSync(
|
|
840
1363
|
path.join(dir, "attestation.json"),
|
|
@@ -858,21 +1381,211 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
858
1381
|
emit(result, pretty);
|
|
859
1382
|
}
|
|
860
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* Resolve the attestation root for a given run. Resolution order (most-specific
|
|
1386
|
+
* first):
|
|
1387
|
+
* 1. --attestation-root <path> explicit caller override
|
|
1388
|
+
* 2. EXCEPTD_HOME env var operator-level configuration
|
|
1389
|
+
* 3. ~/.exceptd/attestations/<repo-or-host-tag>/ default (v0.11.0+)
|
|
1390
|
+
* 4. .exceptd/attestations/ legacy cwd-relative fallback when ~/.exceptd
|
|
1391
|
+
* can't be created (read-only home / sandbox)
|
|
1392
|
+
*
|
|
1393
|
+
* Repo tag is derived from `git config --get remote.origin.url` + branch when
|
|
1394
|
+
* available, else a hostname tag. This means `attest list` works regardless of
|
|
1395
|
+
* which directory you happened to run from. Operators can override via env.
|
|
1396
|
+
*/
|
|
1397
|
+
function resolveAttestationRoot(runOpts) {
|
|
1398
|
+
if (runOpts && runOpts.attestationRoot) return runOpts.attestationRoot;
|
|
1399
|
+
if (process.env.EXCEPTD_HOME) return path.join(process.env.EXCEPTD_HOME, "attestations");
|
|
1400
|
+
const home = require("os").homedir();
|
|
1401
|
+
if (!home) return path.join(process.cwd(), ".exceptd", "attestations");
|
|
1402
|
+
const root = path.join(home, ".exceptd", "attestations", deriveRunTag());
|
|
1403
|
+
try {
|
|
1404
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1405
|
+
return root;
|
|
1406
|
+
} catch {
|
|
1407
|
+
return path.join(process.cwd(), ".exceptd", "attestations");
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Derive a stable tag for attestations: `<repo-name>@<branch>` when in a git
|
|
1413
|
+
* repo, else `host:<hostname>`. Used as the per-context directory under
|
|
1414
|
+
* ~/.exceptd/attestations/ so multi-repo operators don't conflate sessions.
|
|
1415
|
+
*/
|
|
1416
|
+
function deriveRunTag() {
|
|
1417
|
+
const { spawnSync } = require("child_process");
|
|
1418
|
+
try {
|
|
1419
|
+
const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" });
|
|
1420
|
+
if (remote.status === 0 && remote.stdout.trim()) {
|
|
1421
|
+
const url = remote.stdout.trim();
|
|
1422
|
+
const repoName = (url.match(/[\/:]([^/]+?)(?:\.git)?$/) || [, "unknown"])[1];
|
|
1423
|
+
const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
1424
|
+
const branchName = branch.status === 0 ? branch.stdout.trim() : "head";
|
|
1425
|
+
return `${repoName}@${branchName}`.replace(/[^A-Za-z0-9._@-]/g, "_");
|
|
1426
|
+
}
|
|
1427
|
+
} catch {}
|
|
1428
|
+
return `host:${require("os").hostname()}`.replace(/[^A-Za-z0-9._@:-]/g, "_");
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Persist an attestation file. Refuses to overwrite an existing file unless
|
|
1433
|
+
* `forceOverwrite` is true. When force-overwriting, the new attestation
|
|
1434
|
+
* records `prior_session_id` (== current session_id; the prior content is
|
|
1435
|
+
* what's being replaced) plus a `prior_evidence_hash` link extracted from
|
|
1436
|
+
* the file on disk before clobbering — so the audit-trail chain survives.
|
|
1437
|
+
*
|
|
1438
|
+
* Returns { ok: true, prior_session_id?, overwrote_at?, persist_path } on
|
|
1439
|
+
* success; or { ok: false, error, existingPath } when the operator hit a
|
|
1440
|
+
* collision without --force-overwrite.
|
|
1441
|
+
*/
|
|
1442
|
+
function persistAttestation(args) {
|
|
1443
|
+
const { sessionId, playbookId, directiveId, evidenceHash, operator,
|
|
1444
|
+
operatorConsent, submission, runOpts, forceOverwrite, filename } = args;
|
|
1445
|
+
const root = resolveAttestationRoot(runOpts);
|
|
1446
|
+
const dir = path.join(root, sessionId);
|
|
1447
|
+
const filePath = path.join(dir, filename);
|
|
1448
|
+
|
|
1449
|
+
let prior = null;
|
|
1450
|
+
if (fs.existsSync(filePath)) {
|
|
1451
|
+
try { prior = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
|
|
1452
|
+
if (!forceOverwrite) {
|
|
1453
|
+
return {
|
|
1454
|
+
ok: false,
|
|
1455
|
+
error: `Attestation already exists at ${path.relative(process.cwd(), filePath)}. Session-id collision (${sessionId}) — refusing to overwrite to preserve audit trail.`,
|
|
1456
|
+
existingPath: path.relative(process.cwd(), filePath),
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
try {
|
|
1462
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1463
|
+
const attestation = {
|
|
1464
|
+
session_id: sessionId,
|
|
1465
|
+
playbook_id: playbookId,
|
|
1466
|
+
directive_id: directiveId,
|
|
1467
|
+
evidence_hash: evidenceHash,
|
|
1468
|
+
operator: operator || null,
|
|
1469
|
+
operator_consent: operatorConsent || null,
|
|
1470
|
+
submission,
|
|
1471
|
+
run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
|
|
1472
|
+
captured_at: new Date().toISOString(),
|
|
1473
|
+
// When overwriting (with --force-overwrite), link to the prior content
|
|
1474
|
+
// by evidence_hash + capture timestamp. session_id is the same (that's
|
|
1475
|
+
// why we collided), so it's the hash + timestamp that distinguish.
|
|
1476
|
+
prior_evidence_hash: prior ? (prior.evidence_hash || null) : null,
|
|
1477
|
+
prior_captured_at: prior ? (prior.captured_at || null) : null,
|
|
1478
|
+
};
|
|
1479
|
+
fs.writeFileSync(filePath, JSON.stringify(attestation, null, 2));
|
|
1480
|
+
maybeSignAttestation(filePath);
|
|
1481
|
+
return {
|
|
1482
|
+
ok: true,
|
|
1483
|
+
prior_session_id: prior ? sessionId : null,
|
|
1484
|
+
overwrote_at: prior ? prior.captured_at : null,
|
|
1485
|
+
};
|
|
1486
|
+
} catch (e) {
|
|
1487
|
+
return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Ed25519-sign an attestation file when .keys/private.pem is available
|
|
1493
|
+
* (matches lib/sign.js convention for skill signing). Writes a sidecar
|
|
1494
|
+
* `<file>.sig` alongside the attestation. Defense against post-hoc tampering
|
|
1495
|
+
* by anyone who can write to .exceptd/.
|
|
1496
|
+
*
|
|
1497
|
+
* Without a private key, writes a marker file documenting the signed=false
|
|
1498
|
+
* state so downstream tooling can distinguish "operator declined signing"
|
|
1499
|
+
* from "the .sig file was deleted by an attacker."
|
|
1500
|
+
*/
|
|
1501
|
+
function maybeSignAttestation(filePath) {
|
|
1502
|
+
const crypto = require("crypto");
|
|
1503
|
+
const sigPath = filePath + ".sig";
|
|
1504
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
1505
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1506
|
+
// One-time-per-process unsigned warning so cron jobs don't spam stderr.
|
|
1507
|
+
// Operators who set `.keys/private.pem` get tamper-evident attestations;
|
|
1508
|
+
// operators without the keypair get a single nudge per session telling them
|
|
1509
|
+
// exactly how to enable signing.
|
|
1510
|
+
if (!fs.existsSync(privKeyPath) && !process.env.EXCEPTD_UNSIGNED_WARNED) {
|
|
1511
|
+
process.stderr.write(
|
|
1512
|
+
"[attest] attestation will be written UNSIGNED (no private key at .keys/private.pem). " +
|
|
1513
|
+
"Operators reading the attestation later can verify the SHA-256 hash but not authenticity. " +
|
|
1514
|
+
"Enable Ed25519 signing: `node lib/sign.js generate-keypair`. " +
|
|
1515
|
+
"Suppress this notice: export EXCEPTD_UNSIGNED_WARNED=1.\n"
|
|
1516
|
+
);
|
|
1517
|
+
process.env.EXCEPTD_UNSIGNED_WARNED = "1";
|
|
1518
|
+
}
|
|
1519
|
+
try {
|
|
1520
|
+
if (fs.existsSync(privKeyPath)) {
|
|
1521
|
+
const privateKey = fs.readFileSync(privKeyPath, "utf8");
|
|
1522
|
+
const sig = crypto.sign(null, Buffer.from(content, "utf8"), {
|
|
1523
|
+
key: privateKey,
|
|
1524
|
+
dsaEncoding: "ieee-p1363",
|
|
1525
|
+
});
|
|
1526
|
+
fs.writeFileSync(sigPath, JSON.stringify({
|
|
1527
|
+
algorithm: "Ed25519",
|
|
1528
|
+
signature_base64: sig.toString("base64"),
|
|
1529
|
+
signed_at: new Date().toISOString(),
|
|
1530
|
+
signs_path: path.basename(filePath),
|
|
1531
|
+
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
1532
|
+
}, null, 2));
|
|
1533
|
+
} else {
|
|
1534
|
+
fs.writeFileSync(sigPath, JSON.stringify({
|
|
1535
|
+
algorithm: "unsigned",
|
|
1536
|
+
signed: false,
|
|
1537
|
+
signed_at: null,
|
|
1538
|
+
signs_path: path.basename(filePath),
|
|
1539
|
+
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
1540
|
+
note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
|
|
1541
|
+
}, null, 2));
|
|
1542
|
+
}
|
|
1543
|
+
} catch { /* non-fatal — signing failure shouldn't block the run */ }
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Resolve a session-id to its on-disk directory. Searches both the v0.11.0
|
|
1548
|
+
* default root and the legacy cwd-relative root; returns whichever exists.
|
|
1549
|
+
* Returns null if neither has the session.
|
|
1550
|
+
*/
|
|
1551
|
+
function findSessionDir(sessionId, runOpts) {
|
|
1552
|
+
const candidates = [
|
|
1553
|
+
path.join(resolveAttestationRoot(runOpts), sessionId),
|
|
1554
|
+
path.join(process.cwd(), ".exceptd", "attestations", sessionId),
|
|
1555
|
+
];
|
|
1556
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
861
1560
|
/**
|
|
862
1561
|
* Find the latest attestation file under .exceptd/attestations/.
|
|
863
1562
|
* Filters: optional playbook ID and optional "since" ISO timestamp.
|
|
864
1563
|
* Returns { sessionId, playbookId, file, parsed } or null.
|
|
865
1564
|
*/
|
|
866
1565
|
function findLatestAttestation(opts = {}) {
|
|
867
|
-
|
|
868
|
-
|
|
1566
|
+
// Search both the v0.11.0 default root (~/.exceptd/) and the legacy cwd-
|
|
1567
|
+
// relative root so operators with prior attestations don't lose their
|
|
1568
|
+
// history when the default moved.
|
|
1569
|
+
const roots = [resolveAttestationRoot(opts), path.join(process.cwd(), ".exceptd", "attestations")];
|
|
1570
|
+
const seen = new Set();
|
|
1571
|
+
const candidates = [];
|
|
1572
|
+
for (const root of roots) {
|
|
1573
|
+
if (seen.has(root) || !fs.existsSync(root)) continue;
|
|
1574
|
+
seen.add(root);
|
|
1575
|
+
walkAttestationDir(root, opts, candidates);
|
|
1576
|
+
}
|
|
1577
|
+
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
1578
|
+
return candidates[0] || null;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function walkAttestationDir(root, opts, candidates) {
|
|
1582
|
+
if (!fs.existsSync(root)) return;
|
|
869
1583
|
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
870
1584
|
.filter(d => d.isDirectory())
|
|
871
1585
|
.map(d => d.name);
|
|
872
|
-
const candidates = [];
|
|
873
1586
|
for (const sid of sessions) {
|
|
874
1587
|
const sdir = path.join(root, sid);
|
|
875
|
-
for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
|
|
1588
|
+
for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json") && !x.endsWith(".sig"))) {
|
|
876
1589
|
try {
|
|
877
1590
|
const p = path.join(sdir, f);
|
|
878
1591
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
@@ -883,8 +1596,6 @@ function findLatestAttestation(opts = {}) {
|
|
|
883
1596
|
} catch { /* skip malformed */ }
|
|
884
1597
|
}
|
|
885
1598
|
}
|
|
886
|
-
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
887
|
-
return candidates[0] || null;
|
|
888
1599
|
}
|
|
889
1600
|
|
|
890
1601
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
@@ -902,10 +1613,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
902
1613
|
attFile = found.file;
|
|
903
1614
|
}
|
|
904
1615
|
if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
|
|
905
|
-
const dir = path.join(
|
|
1616
|
+
const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
|
|
906
1617
|
if (!attFile) attFile = path.join(dir, "attestation.json");
|
|
907
1618
|
if (!fs.existsSync(attFile)) {
|
|
908
|
-
return emitError(`reattest: no attestation found at ${
|
|
1619
|
+
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
909
1620
|
}
|
|
910
1621
|
let prior;
|
|
911
1622
|
try {
|
|
@@ -975,33 +1686,640 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
975
1686
|
}, pretty);
|
|
976
1687
|
}
|
|
977
1688
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1689
|
+
/**
|
|
1690
|
+
* `exceptd attest <subverb> <session-id>` — auditor-facing operations on
|
|
1691
|
+
* persisted attestations. Subverbs:
|
|
1692
|
+
* export <session-id> Emit redacted JSON suitable for audit submission.
|
|
1693
|
+
* Strips raw artifact values; preserves only
|
|
1694
|
+
* evidence_hash + signatures + classification + RWEP.
|
|
1695
|
+
* Falls back to a CSAF-shaped envelope when --format csaf.
|
|
1696
|
+
* verify <session-id> Verify the .sig sidecar against keys/public.pem.
|
|
1697
|
+
* Reports signed_by + tamper status.
|
|
1698
|
+
* show <session-id> Emit the full (unredacted) attestation. Convenience
|
|
1699
|
+
* alias for `cat .exceptd/attestations/<sid>/attestation.json`.
|
|
1700
|
+
*/
|
|
1701
|
+
function cmdAttest(runner, args, runOpts, pretty) {
|
|
1702
|
+
const subverb = args._[0];
|
|
1703
|
+
const sessionId = args._[1];
|
|
1704
|
+
if (!subverb) {
|
|
1705
|
+
return emitError("attest: missing subverb. Usage: attest list | show <sid> | export <sid> | verify <sid> | diff <sid>", null, pretty);
|
|
1706
|
+
}
|
|
1707
|
+
// `list` doesn't require a session-id positional.
|
|
1708
|
+
if (subverb === "list") {
|
|
1709
|
+
return cmdListAttestations(runner, args, runOpts, pretty);
|
|
1710
|
+
}
|
|
1711
|
+
if (!sessionId) {
|
|
1712
|
+
return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
|
|
1713
|
+
}
|
|
1714
|
+
const dir = findSessionDir(sessionId, runOpts);
|
|
1715
|
+
if (!dir) {
|
|
1716
|
+
return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
|
|
982
1717
|
}
|
|
983
|
-
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
984
|
-
.filter(d => d.isDirectory())
|
|
985
|
-
.map(d => d.name);
|
|
986
1718
|
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1719
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
1720
|
+
const attestations = files.map(f => {
|
|
1721
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
1722
|
+
catch { return null; }
|
|
1723
|
+
}).filter(Boolean);
|
|
1724
|
+
|
|
1725
|
+
if (subverb === "show") {
|
|
1726
|
+
emit({ session_id: sessionId, attestations }, pretty);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (subverb === "diff") {
|
|
1731
|
+
// `attest diff <session-id> [--against <other-session-id>]` — drift
|
|
1732
|
+
// comparison. Without --against, replays current state against prior
|
|
1733
|
+
// session (= reattest). With --against, compares two sessions A vs B
|
|
1734
|
+
// by evidence_hash + artifact-level field diff.
|
|
1735
|
+
if (args.against) {
|
|
1736
|
+
const otherDir = findSessionDir(args.against, runOpts);
|
|
1737
|
+
if (!otherDir) {
|
|
1738
|
+
return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
|
|
1739
|
+
}
|
|
1740
|
+
const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
1741
|
+
if (otherFiles.length === 0) {
|
|
1742
|
+
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
1743
|
+
}
|
|
1744
|
+
const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
|
|
1745
|
+
const self = attestations[0];
|
|
1746
|
+
emit({
|
|
1747
|
+
verb: "attest diff",
|
|
1748
|
+
a_session: sessionId,
|
|
1749
|
+
b_session: args.against,
|
|
1750
|
+
a_captured: self.captured_at,
|
|
1751
|
+
b_captured: other.captured_at,
|
|
1752
|
+
a_evidence_hash: self.evidence_hash,
|
|
1753
|
+
b_evidence_hash: other.evidence_hash,
|
|
1754
|
+
status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
|
|
1755
|
+
artifact_diff: diffArtifacts((self.submission || {}).artifacts, (other.submission || {}).artifacts),
|
|
1756
|
+
signal_override_diff: diffSignalOverrides((self.submission || {}).signal_overrides, (other.submission || {}).signal_overrides),
|
|
1757
|
+
}, pretty);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
// Fall through to reattest-style replay below by setting subverb to a
|
|
1761
|
+
// sentinel and re-dispatching via cmdReattest.
|
|
1762
|
+
args._ = [sessionId];
|
|
1763
|
+
return cmdReattest(runner, args, {}, pretty);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
if (subverb === "list") {
|
|
1767
|
+
return cmdListAttestations(runner, args, {}, pretty);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
if (subverb === "verify") {
|
|
1771
|
+
const crypto = require("crypto");
|
|
1772
|
+
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
1773
|
+
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
1774
|
+
const results = files.map(f => {
|
|
1775
|
+
const sigPath = path.join(dir, f + ".sig");
|
|
1776
|
+
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
1777
|
+
const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
|
|
1778
|
+
if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
1779
|
+
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
1780
|
+
const content = fs.readFileSync(path.join(dir, f), "utf8");
|
|
992
1781
|
try {
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1782
|
+
const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
|
|
1783
|
+
key: pubKey, dsaEncoding: "ieee-p1363",
|
|
1784
|
+
}, Buffer.from(sigDoc.signature_base64, "base64"));
|
|
1785
|
+
return { file: f, signed: true, verified: !!ok, reason: ok ? "Ed25519 signature valid" : "Ed25519 signature INVALID — possible post-hoc tampering" };
|
|
1786
|
+
} catch (e) {
|
|
1787
|
+
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
emit({ verb: "attest verify", session_id: sessionId, results }, pretty);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
if (subverb === "export") {
|
|
1795
|
+
// Redaction: strip raw `value` fields from submitted artifacts; preserve
|
|
1796
|
+
// captured-state flag, evidence_hash, classification, RWEP, confidence,
|
|
1797
|
+
// remediation choice, residual risk acceptance, signature. Auditors get
|
|
1798
|
+
// what they need (the verdict + proof of process) without leaking raw
|
|
1799
|
+
// captured data (which may contain PII / secret shapes).
|
|
1800
|
+
const format = args.format || "json";
|
|
1801
|
+
const redacted = attestations.map(a => ({
|
|
1802
|
+
session_id: a.session_id,
|
|
1803
|
+
playbook_id: a.playbook_id,
|
|
1804
|
+
directive_id: a.directive_id,
|
|
1805
|
+
evidence_hash: a.evidence_hash,
|
|
1806
|
+
operator: a.operator,
|
|
1807
|
+
operator_consent: a.operator_consent,
|
|
1808
|
+
captured_at: a.captured_at,
|
|
1809
|
+
run_opts: a.run_opts,
|
|
1810
|
+
artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
|
|
1811
|
+
.map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
|
|
1812
|
+
signal_overrides: (a.submission && a.submission.signal_overrides) || {},
|
|
1813
|
+
signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
|
|
1814
|
+
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))),
|
|
1815
|
+
precondition_checks: (a.submission && a.submission.precondition_checks) || {},
|
|
1816
|
+
}));
|
|
1817
|
+
|
|
1818
|
+
if (format === "csaf") {
|
|
1819
|
+
// Lightweight CSAF envelope for audit submission — caller can post this
|
|
1820
|
+
// directly to a CSAF-aware GRC platform.
|
|
1821
|
+
emit({
|
|
1822
|
+
document: {
|
|
1823
|
+
category: "csaf_security_advisory",
|
|
1824
|
+
csaf_version: "2.0",
|
|
1825
|
+
publisher: { category: "vendor", name: "exceptd", namespace: "https://exceptd.com" },
|
|
1826
|
+
title: `Auditor export — session ${sessionId}`,
|
|
1827
|
+
tracking: { id: `exceptd-export-${sessionId}`, status: "final", version: "1", initial_release_date: new Date().toISOString() },
|
|
1828
|
+
},
|
|
1829
|
+
exceptd_export: { session_id: sessionId, attestations: redacted, exported_at: new Date().toISOString(), redaction_policy: "v0.10.3-default" },
|
|
1830
|
+
}, pretty);
|
|
1831
|
+
} else {
|
|
1832
|
+
emit({
|
|
1833
|
+
verb: "attest export",
|
|
1834
|
+
session_id: sessionId,
|
|
1835
|
+
exported_at: new Date().toISOString(),
|
|
1836
|
+
redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides + precondition_checks + evidence_hash + signature preserved.",
|
|
1837
|
+
attestations: redacted,
|
|
1838
|
+
}, pretty);
|
|
1839
|
+
}
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Per-artifact diff between two submissions. Returns { added, removed, changed }
|
|
1848
|
+
* keyed by artifact id. Used by `attest diff` (bug #34 fix) so operators get
|
|
1849
|
+
* field-level context instead of a binary evidence_hash signal.
|
|
1850
|
+
*/
|
|
1851
|
+
function diffArtifacts(a, b) {
|
|
1852
|
+
a = a || {}; b = b || {};
|
|
1853
|
+
const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
1854
|
+
const out = { added: [], removed: [], changed: [], unchanged_count: 0 };
|
|
1855
|
+
for (const id of allIds) {
|
|
1856
|
+
const av = a[id], bv = b[id];
|
|
1857
|
+
if (!av && bv) out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
|
|
1858
|
+
else if (av && !bv) out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
|
|
1859
|
+
else if (JSON.stringify(av) !== JSON.stringify(bv)) {
|
|
1860
|
+
out.changed.push({
|
|
1861
|
+
id,
|
|
1862
|
+
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
1863
|
+
a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
|
|
1864
|
+
});
|
|
1865
|
+
} else { out.unchanged_count++; }
|
|
1866
|
+
}
|
|
1867
|
+
return out;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function diffSignalOverrides(a, b) {
|
|
1871
|
+
a = a || {}; b = b || {};
|
|
1872
|
+
const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
1873
|
+
const out = { changed: [], unchanged_count: 0 };
|
|
1874
|
+
for (const id of allIds) {
|
|
1875
|
+
if (a[id] !== b[id]) out.changed.push({ id, a: a[id] || null, b: b[id] || null });
|
|
1876
|
+
else out.unchanged_count++;
|
|
1877
|
+
}
|
|
1878
|
+
return out;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function previewValue(v) {
|
|
1882
|
+
if (v === null || v === undefined) return null;
|
|
1883
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
1884
|
+
return s.length > 80 ? s.slice(0, 80) + "…" : s;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// ---------------------------------------------------------------------------
|
|
1888
|
+
// v0.11.0: cmdDiscover — context-aware playbook recommender.
|
|
1889
|
+
// Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
|
|
1890
|
+
// /etc/os-release on Linux, and outputs a list of recommended playbooks.
|
|
1891
|
+
// ---------------------------------------------------------------------------
|
|
1892
|
+
function cmdDiscover(runner, args, runOpts, pretty) {
|
|
1893
|
+
const cwd = process.cwd();
|
|
1894
|
+
const wantJson = !!args.json || !!args.pretty;
|
|
1895
|
+
const indent = !!args.pretty;
|
|
1896
|
+
|
|
1897
|
+
// File-presence sniffer. Each probe is independently fault-tolerant so a
|
|
1898
|
+
// permission error on one path can't poison the whole detection.
|
|
1899
|
+
const detected = [];
|
|
1900
|
+
function probe(rel, label) {
|
|
1901
|
+
try {
|
|
1902
|
+
if (fs.existsSync(path.join(cwd, rel))) detected.push(label || rel);
|
|
1903
|
+
} catch { /* swallow */ }
|
|
1904
|
+
}
|
|
1905
|
+
probe(".git", ".git/");
|
|
1906
|
+
probe("package.json");
|
|
1907
|
+
probe("package-lock.json");
|
|
1908
|
+
probe("yarn.lock");
|
|
1909
|
+
probe("pnpm-lock.yaml");
|
|
1910
|
+
probe("pyproject.toml");
|
|
1911
|
+
probe("requirements.txt");
|
|
1912
|
+
probe("Pipfile");
|
|
1913
|
+
probe("Cargo.toml");
|
|
1914
|
+
probe("go.mod");
|
|
1915
|
+
probe("Dockerfile");
|
|
1916
|
+
probe("docker-compose.yml");
|
|
1917
|
+
probe("docker-compose.yaml");
|
|
1918
|
+
probe("kustomization.yaml");
|
|
1919
|
+
probe("k8s", "k8s/");
|
|
1920
|
+
probe(".env");
|
|
1921
|
+
probe(".envrc");
|
|
1922
|
+
|
|
1923
|
+
// Terraform / IaC — glob the top level for *.tf.
|
|
1924
|
+
try {
|
|
1925
|
+
const tfFiles = fs.readdirSync(cwd).filter(f => f.endsWith(".tf"));
|
|
1926
|
+
if (tfFiles.length) detected.push(`*.tf (${tfFiles.length})`);
|
|
1927
|
+
} catch { /* swallow */ }
|
|
1928
|
+
|
|
1929
|
+
// Git remote (best-effort, never fatal).
|
|
1930
|
+
let gitRemote = null;
|
|
1931
|
+
if (detected.includes(".git/")) {
|
|
1932
|
+
try {
|
|
1933
|
+
const headPath = path.join(cwd, ".git", "config");
|
|
1934
|
+
if (fs.existsSync(headPath)) {
|
|
1935
|
+
const cfg = fs.readFileSync(headPath, "utf8");
|
|
1936
|
+
const m = cfg.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(\S+)/);
|
|
1937
|
+
if (m) gitRemote = m[1];
|
|
1938
|
+
}
|
|
1939
|
+
} catch { /* swallow */ }
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Host platform / distro.
|
|
1943
|
+
const hostPlatform = process.platform;
|
|
1944
|
+
let hostDistro = null;
|
|
1945
|
+
if (hostPlatform === "linux") {
|
|
1946
|
+
try {
|
|
1947
|
+
const res = spawnSync("cat", ["/etc/os-release"], { encoding: "utf8" });
|
|
1948
|
+
if (res.status === 0 && res.stdout) {
|
|
1949
|
+
const idMatch = res.stdout.match(/^ID=(.+)$/m);
|
|
1950
|
+
const verMatch = res.stdout.match(/^VERSION_ID=(.+)$/m);
|
|
1951
|
+
const prettyMatch = res.stdout.match(/^PRETTY_NAME=(.+)$/m);
|
|
1952
|
+
hostDistro = {
|
|
1953
|
+
id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
|
|
1954
|
+
version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
|
|
1955
|
+
pretty_name: prettyMatch ? prettyMatch[1].replace(/^"|"$/g, "") : null,
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
} catch { /* swallow */ }
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// Build recommendation set. Dedup by playbook id so multi-trigger rules
|
|
1962
|
+
// don't double-list.
|
|
1963
|
+
const isRepo = detected.includes(".git/");
|
|
1964
|
+
const hasNode = detected.includes("package.json") || detected.includes("package-lock.json")
|
|
1965
|
+
|| detected.includes("yarn.lock") || detected.includes("pnpm-lock.yaml");
|
|
1966
|
+
const hasPython = detected.includes("pyproject.toml") || detected.includes("requirements.txt")
|
|
1967
|
+
|| detected.includes("Pipfile");
|
|
1968
|
+
const hasRust = detected.includes("Cargo.toml");
|
|
1969
|
+
const hasGo = detected.includes("go.mod");
|
|
1970
|
+
const hasLockfile = hasNode || hasPython || hasRust || hasGo;
|
|
1971
|
+
const hasContainers = detected.includes("Dockerfile") || detected.includes("docker-compose.yml")
|
|
1972
|
+
|| detected.includes("docker-compose.yaml");
|
|
1973
|
+
const isLinux = hostPlatform === "linux";
|
|
1974
|
+
|
|
1975
|
+
const recs = [];
|
|
1976
|
+
const seen = new Set();
|
|
1977
|
+
function recommend(id, reason) {
|
|
1978
|
+
if (seen.has(id)) return;
|
|
1979
|
+
seen.add(id);
|
|
1980
|
+
recs.push({ id, reason });
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
if (isRepo && hasLockfile) {
|
|
1984
|
+
const langs = [hasNode && "node", hasPython && "python", hasRust && "rust", hasGo && "go"]
|
|
1985
|
+
.filter(Boolean).join("/");
|
|
1986
|
+
recommend("secrets", `git repo + ${langs} lockfile → check for committed credentials`);
|
|
1987
|
+
recommend("sbom", `git repo + ${langs} lockfile → SBOM + supply-chain integrity`);
|
|
1988
|
+
recommend("library-author", `git repo + ${langs} lockfile → publisher-side audit`);
|
|
1989
|
+
recommend("crypto-codebase", `git repo + ${langs} lockfile → cryptographic primitive review`);
|
|
1990
|
+
}
|
|
1991
|
+
if (hasContainers) {
|
|
1992
|
+
recommend("containers", "Dockerfile / docker-compose present → container security review");
|
|
1993
|
+
}
|
|
1994
|
+
if (isLinux) {
|
|
1995
|
+
recommend("kernel", "Linux host detected → kernel LPE / privilege escalation triage");
|
|
1996
|
+
recommend("hardening", "Linux host detected → system hardening review");
|
|
1997
|
+
recommend("runtime", "Linux host detected → runtime behavior review");
|
|
1998
|
+
recommend("cred-stores", "Linux host detected → credential store review");
|
|
1999
|
+
}
|
|
2000
|
+
// Always include cross-cutting framework correlation.
|
|
2001
|
+
recommend("framework", "cross-cutting: framework correlation always applicable");
|
|
2002
|
+
|
|
2003
|
+
const nextSteps = [
|
|
2004
|
+
"exceptd brief <playbook> # learn what a playbook checks",
|
|
2005
|
+
"exceptd run <playbook> # run it",
|
|
2006
|
+
"exceptd run --scope code # run all code-scoped playbooks (auto-detected)",
|
|
2007
|
+
"exceptd ci --scope code # CI-gate against all code-scoped playbooks",
|
|
2008
|
+
];
|
|
2009
|
+
|
|
2010
|
+
const out = {
|
|
2011
|
+
verb: "discover",
|
|
2012
|
+
context: {
|
|
2013
|
+
cwd,
|
|
2014
|
+
git_remote: gitRemote,
|
|
2015
|
+
detected_files: detected,
|
|
2016
|
+
host_platform: hostPlatform,
|
|
2017
|
+
host_distro: hostDistro,
|
|
2018
|
+
},
|
|
2019
|
+
recommended_playbooks: recs,
|
|
2020
|
+
next_steps: nextSteps,
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
// --scan-only: also run legacy `scan` and embed under legacy_scan. Use
|
|
2024
|
+
// spawnSync against orchestrator/index.js — the orchestrator was designed
|
|
2025
|
+
// to be invoked as a subprocess, and isolating it via spawn prevents one
|
|
2026
|
+
// bad scanner from killing the whole discover verb.
|
|
2027
|
+
if (args["scan-only"]) {
|
|
2028
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2029
|
+
try {
|
|
2030
|
+
const res = spawnSync(process.execPath, [orchPath, "scan", "--json"], {
|
|
2031
|
+
encoding: "utf8",
|
|
2032
|
+
cwd,
|
|
2033
|
+
timeout: 30000,
|
|
2034
|
+
});
|
|
2035
|
+
if (res.status === 0 && res.stdout) {
|
|
2036
|
+
try { out.legacy_scan = JSON.parse(res.stdout); }
|
|
2037
|
+
catch { out.legacy_scan = { ok: false, raw: res.stdout.slice(0, 2000), parse_error: true }; }
|
|
2038
|
+
} else {
|
|
2039
|
+
out.legacy_scan = {
|
|
2040
|
+
ok: false,
|
|
2041
|
+
exit_code: res.status,
|
|
2042
|
+
stderr: (res.stderr || "").slice(0, 2000),
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
} catch (e) {
|
|
2046
|
+
out.legacy_scan = { ok: false, error: e.message };
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
if (wantJson) {
|
|
2051
|
+
emit(out, indent);
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Default: human-readable text. (v0.11.0 redesign #5 — flipped defaults.)
|
|
2056
|
+
const lines = [];
|
|
2057
|
+
lines.push("exceptd discover");
|
|
2058
|
+
lines.push(` cwd: ${cwd}`);
|
|
2059
|
+
if (gitRemote) lines.push(` git remote: ${gitRemote}`);
|
|
2060
|
+
lines.push(` platform: ${hostPlatform}${hostDistro && hostDistro.pretty_name ? " (" + hostDistro.pretty_name + ")" : ""}`);
|
|
2061
|
+
lines.push(` detected: ${detected.length ? detected.join(", ") : "(nothing recognized)"}`);
|
|
2062
|
+
lines.push("");
|
|
2063
|
+
lines.push(`Recommended playbooks (${recs.length}):`);
|
|
2064
|
+
for (const r of recs) {
|
|
2065
|
+
lines.push(` - ${r.id.padEnd(20)} ${r.reason}`);
|
|
2066
|
+
}
|
|
2067
|
+
lines.push("");
|
|
2068
|
+
lines.push("Next steps:");
|
|
2069
|
+
for (const s of nextSteps) lines.push(` ${s}`);
|
|
2070
|
+
if (out.legacy_scan) {
|
|
2071
|
+
lines.push("");
|
|
2072
|
+
lines.push(`legacy scan: ${out.legacy_scan.ok === false ? "FAILED" : "ok"}`);
|
|
2073
|
+
}
|
|
2074
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// ---------------------------------------------------------------------------
|
|
2078
|
+
// v0.11.0: cmdDoctor — one-shot health check.
|
|
2079
|
+
// Collapses verify + currency + validate-cves + validate-rfcs + signing-status.
|
|
2080
|
+
// Each subcheck is independently fault-tolerant: a single failure surfaces
|
|
2081
|
+
// in the JSON but never crashes the verb.
|
|
2082
|
+
// ---------------------------------------------------------------------------
|
|
2083
|
+
function cmdDoctor(runner, args, runOpts, pretty) {
|
|
2084
|
+
const wantJson = !!args.json || !!args.pretty;
|
|
2085
|
+
const indent = !!args.pretty;
|
|
2086
|
+
|
|
2087
|
+
// Selective subchecks. If any of the four flags is passed, run only those.
|
|
2088
|
+
// If none are passed, run all four plus signing-status.
|
|
2089
|
+
const onlySigs = !!args.signatures;
|
|
2090
|
+
const onlyCurrency = !!args.currency;
|
|
2091
|
+
const onlyCves = !!args.cves;
|
|
2092
|
+
const onlyRfcs = !!args.rfcs;
|
|
2093
|
+
const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs;
|
|
2094
|
+
const runSigs = !anySelected || onlySigs;
|
|
2095
|
+
const runCurrency = !anySelected || onlyCurrency;
|
|
2096
|
+
const runCves = !anySelected || onlyCves;
|
|
2097
|
+
const runRfcs = !anySelected || onlyRfcs;
|
|
2098
|
+
const runSigning = !anySelected;
|
|
2099
|
+
|
|
2100
|
+
const checks = {};
|
|
2101
|
+
const issues = [];
|
|
2102
|
+
|
|
2103
|
+
if (runSigs) {
|
|
2104
|
+
try {
|
|
2105
|
+
const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
|
|
2106
|
+
const res = spawnSync(process.execPath, [verifyPath], {
|
|
2107
|
+
encoding: "utf8",
|
|
2108
|
+
cwd: PKG_ROOT,
|
|
2109
|
+
timeout: 30000,
|
|
2110
|
+
});
|
|
2111
|
+
const text = (res.stdout || "") + (res.stderr || "");
|
|
2112
|
+
const okMatch = text.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
|
|
2113
|
+
const fpMatch = text.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
|
|
2114
|
+
const ok = res.status === 0;
|
|
2115
|
+
checks.signatures = {
|
|
2116
|
+
ok,
|
|
2117
|
+
skills_passed: okMatch ? Number(okMatch[1]) : null,
|
|
2118
|
+
skills_total: okMatch ? Number(okMatch[2]) : null,
|
|
2119
|
+
fingerprint_sha256: fpMatch ? fpMatch[1] : null,
|
|
2120
|
+
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2121
|
+
};
|
|
2122
|
+
if (!ok) issues.push("signatures");
|
|
2123
|
+
} catch (e) {
|
|
2124
|
+
checks.signatures = { ok: false, error: e.message };
|
|
2125
|
+
issues.push("signatures");
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
if (runCurrency) {
|
|
2130
|
+
try {
|
|
2131
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2132
|
+
const res = spawnSync(process.execPath, [orchPath, "currency", "--json"], {
|
|
2133
|
+
encoding: "utf8",
|
|
2134
|
+
cwd: PKG_ROOT,
|
|
2135
|
+
timeout: 30000,
|
|
2136
|
+
});
|
|
2137
|
+
let parsed = null;
|
|
2138
|
+
if (res.stdout) {
|
|
2139
|
+
const m = res.stdout.match(/\{[\s\S]*\}\s*$/);
|
|
2140
|
+
if (m) {
|
|
2141
|
+
try { parsed = JSON.parse(m[0]); } catch { /* fall through */ }
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
if (parsed && Array.isArray(parsed.currency_report)) {
|
|
2145
|
+
const stale = parsed.currency_report.filter(s => s.action_required || s.currency_label !== "current");
|
|
2146
|
+
const critical = parsed.currency_report.filter(s => s.currency_score !== undefined && s.currency_score < 50);
|
|
2147
|
+
const ok = stale.length === 0 && !parsed.action_required;
|
|
2148
|
+
checks.currency = {
|
|
2149
|
+
ok,
|
|
2150
|
+
total_skills: parsed.currency_report.length,
|
|
2151
|
+
stale_skills: stale.map(s => s.skill),
|
|
2152
|
+
critical_stale: critical.map(s => s.skill),
|
|
2153
|
+
critical_count: parsed.critical_count || 0,
|
|
2154
|
+
};
|
|
2155
|
+
if (!ok) issues.push("currency");
|
|
2156
|
+
} else {
|
|
2157
|
+
checks.currency = {
|
|
2158
|
+
ok: res.status === 0,
|
|
2159
|
+
exit_code: res.status,
|
|
2160
|
+
raw: (res.stdout || res.stderr || "").slice(0, 500),
|
|
2161
|
+
parse_error: true,
|
|
2162
|
+
};
|
|
2163
|
+
if (res.status !== 0) issues.push("currency");
|
|
2164
|
+
}
|
|
2165
|
+
} catch (e) {
|
|
2166
|
+
checks.currency = { ok: false, error: e.message };
|
|
2167
|
+
issues.push("currency");
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (runCves) {
|
|
2172
|
+
try {
|
|
2173
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2174
|
+
// validate-cves doesn't emit JSON; parse text for row count + drift.
|
|
2175
|
+
const res = spawnSync(process.execPath, [orchPath, "validate-cves", "--offline"], {
|
|
2176
|
+
encoding: "utf8",
|
|
2177
|
+
cwd: PKG_ROOT,
|
|
2178
|
+
timeout: 30000,
|
|
2179
|
+
});
|
|
2180
|
+
const text = (res.stdout || "") + (res.stderr || "");
|
|
2181
|
+
const totalMatch = text.match(/(\d+)\s+CVEs?\s+in\s+catalog/i);
|
|
2182
|
+
const driftMatch = text.match(/drift[:\s]+(\d+)/i);
|
|
2183
|
+
const ok = res.status === 0;
|
|
2184
|
+
checks.cves = {
|
|
2185
|
+
ok,
|
|
2186
|
+
total: totalMatch ? Number(totalMatch[1]) : null,
|
|
2187
|
+
drift: driftMatch ? Number(driftMatch[1]) : 0,
|
|
2188
|
+
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2189
|
+
};
|
|
2190
|
+
if (!ok) issues.push("cves");
|
|
2191
|
+
} catch (e) {
|
|
2192
|
+
checks.cves = { ok: false, error: e.message };
|
|
2193
|
+
issues.push("cves");
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (runRfcs) {
|
|
2198
|
+
try {
|
|
2199
|
+
const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
|
|
2200
|
+
const res = spawnSync(process.execPath, [orchPath, "validate-rfcs", "--offline"], {
|
|
2201
|
+
encoding: "utf8",
|
|
2202
|
+
cwd: PKG_ROOT,
|
|
2203
|
+
timeout: 30000,
|
|
2204
|
+
});
|
|
2205
|
+
const text = (res.stdout || "") + (res.stderr || "");
|
|
2206
|
+
const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
|
|
2207
|
+
const driftMatch = text.match(/drift[:\s]+(\d+)/i);
|
|
2208
|
+
const ok = res.status === 0;
|
|
2209
|
+
checks.rfcs = {
|
|
2210
|
+
ok,
|
|
2211
|
+
total: rfcRows,
|
|
2212
|
+
drift: driftMatch ? Number(driftMatch[1]) : 0,
|
|
2213
|
+
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2214
|
+
};
|
|
2215
|
+
if (!ok) issues.push("rfcs");
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
checks.rfcs = { ok: false, error: e.message };
|
|
2218
|
+
issues.push("rfcs");
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
if (runSigning) {
|
|
2223
|
+
try {
|
|
2224
|
+
const keyPath = path.join(process.cwd(), ".keys", "private.pem");
|
|
2225
|
+
const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2226
|
+
const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
|
|
2227
|
+
checks.signing = {
|
|
2228
|
+
ok: true, // signing-status is informational, never "fails"
|
|
2229
|
+
private_key_present: present,
|
|
2230
|
+
can_sign_attestations: present,
|
|
2231
|
+
...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` to enable attestation signing" }),
|
|
2232
|
+
};
|
|
2233
|
+
} catch (e) {
|
|
2234
|
+
checks.signing = { ok: false, error: e.message };
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const allGreen = issues.length === 0;
|
|
2239
|
+
const out = {
|
|
2240
|
+
verb: "doctor",
|
|
2241
|
+
checks,
|
|
2242
|
+
summary: { all_green: allGreen, issues_count: issues.length, failed_checks: issues },
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
if (wantJson) {
|
|
2246
|
+
emit(out, indent);
|
|
2247
|
+
if (!allGreen) process.exitCode = 1;
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// Default: human checklist. v0.11.0 redesign #5.
|
|
2252
|
+
const lines = [];
|
|
2253
|
+
lines.push("exceptd doctor");
|
|
2254
|
+
function mark(c, render) {
|
|
2255
|
+
if (!c) return;
|
|
2256
|
+
const icon = c.ok ? "[ok]" : "[!!]";
|
|
2257
|
+
lines.push(` ${icon} ${render(c)}`);
|
|
2258
|
+
}
|
|
2259
|
+
mark(checks.signatures, c =>
|
|
2260
|
+
c.ok
|
|
2261
|
+
? `skill signatures verified (${c.skills_passed ?? "?"}/${c.skills_total ?? "?"})`
|
|
2262
|
+
: `skill signatures FAILED (exit=${c.exit_code ?? "?"})`
|
|
2263
|
+
);
|
|
2264
|
+
mark(checks.currency, c =>
|
|
2265
|
+
c.ok
|
|
2266
|
+
? `skill currency: all green (${c.total_skills ?? "?"} skills)`
|
|
2267
|
+
: `skill currency: ${c.stale_skills?.length || "?"} stale, ${c.critical_count ?? 0} critical`
|
|
2268
|
+
);
|
|
2269
|
+
mark(checks.cves, c =>
|
|
2270
|
+
c.ok
|
|
2271
|
+
? `CVE catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
|
|
2272
|
+
: `CVE catalog FAILED (exit=${c.exit_code ?? "?"})`
|
|
2273
|
+
);
|
|
2274
|
+
mark(checks.rfcs, c =>
|
|
2275
|
+
c.ok
|
|
2276
|
+
? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
|
|
2277
|
+
: `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
|
|
2278
|
+
);
|
|
2279
|
+
if (checks.signing) {
|
|
2280
|
+
if (checks.signing.private_key_present) {
|
|
2281
|
+
lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
|
|
2282
|
+
} else {
|
|
2283
|
+
lines.push(` [!!] attestation signing: private key MISSING (.keys/private.pem) — run \`node lib/sign.js generate-keypair\` to enable`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
lines.push("");
|
|
2287
|
+
lines.push(allGreen ? `summary: all checks green` : `summary: ${issues.length} issue(s) — ${issues.join(", ")}`);
|
|
2288
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2289
|
+
if (!allGreen) process.exitCode = 1;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
2293
|
+
// Enumerate sessions across both v0.11.0 default root and legacy cwd-
|
|
2294
|
+
// relative root, so operators with prior attestations still see them.
|
|
2295
|
+
const roots = [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")];
|
|
2296
|
+
const entries = [];
|
|
2297
|
+
const seenRoots = new Set();
|
|
2298
|
+
for (const root of roots) {
|
|
2299
|
+
if (seenRoots.has(root) || !fs.existsSync(root)) continue;
|
|
2300
|
+
seenRoots.add(root);
|
|
2301
|
+
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
2302
|
+
.filter(d => d.isDirectory())
|
|
2303
|
+
.map(d => d.name);
|
|
2304
|
+
for (const sid of sessions) {
|
|
2305
|
+
const sdir = path.join(root, sid);
|
|
2306
|
+
const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
2307
|
+
for (const f of files) {
|
|
2308
|
+
try {
|
|
2309
|
+
const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
|
|
2310
|
+
if (args.playbook && j.playbook_id !== args.playbook) continue;
|
|
2311
|
+
if (args.since && (j.captured_at || "") < args.since) continue;
|
|
2312
|
+
entries.push({
|
|
2313
|
+
session_id: sid,
|
|
2314
|
+
playbook_id: j.playbook_id,
|
|
2315
|
+
directive_id: j.directive_id,
|
|
2316
|
+
evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
|
|
2317
|
+
captured_at: j.captured_at || null,
|
|
2318
|
+
attestation_root: root,
|
|
2319
|
+
file: path.join(sdir, f),
|
|
2320
|
+
});
|
|
2321
|
+
} catch { /* skip malformed */ }
|
|
2322
|
+
}
|
|
1005
2323
|
}
|
|
1006
2324
|
}
|
|
1007
2325
|
entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
|
|
@@ -1009,10 +2327,423 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
1009
2327
|
ok: true,
|
|
1010
2328
|
attestations: entries,
|
|
1011
2329
|
count: entries.length,
|
|
1012
|
-
filter: { playbook: args.playbook || null },
|
|
2330
|
+
filter: { playbook: args.playbook || null, since: args.since || null },
|
|
2331
|
+
roots_searched: [...seenRoots],
|
|
1013
2332
|
}, pretty);
|
|
1014
2333
|
}
|
|
1015
2334
|
|
|
2335
|
+
// ---------------------------------------------------------------------------
|
|
2336
|
+
// v0.11.0 verbs: ai-run, ask, ci
|
|
2337
|
+
// ---------------------------------------------------------------------------
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* `ai-run <playbook>` — streaming JSONL contract for AI-driven runs.
|
|
2341
|
+
*
|
|
2342
|
+
* Emits one JSON object per line over stdout as the seven phases progress;
|
|
2343
|
+
* reads {"event":"evidence","payload":{observations,verdict}} from stdin
|
|
2344
|
+
* once it's announced the await_evidence phase. Designed so a host AI can
|
|
2345
|
+
* pipe one bidirectional channel instead of doing brief → look → run as
|
|
2346
|
+
* three CLI round-trips with an intermediate evidence file.
|
|
2347
|
+
*
|
|
2348
|
+
* --no-stream falls back to a single JSON document combining every phase
|
|
2349
|
+
* for callers that don't want event-driven I/O (smoke tests, batch jobs).
|
|
2350
|
+
*/
|
|
2351
|
+
function cmdAiRun(runner, args, runOpts, pretty) {
|
|
2352
|
+
const playbookId = args._[0];
|
|
2353
|
+
if (!playbookId) {
|
|
2354
|
+
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
2355
|
+
}
|
|
2356
|
+
let pb;
|
|
2357
|
+
try { pb = runner.loadPlaybook(playbookId); }
|
|
2358
|
+
catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
|
|
2359
|
+
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2360
|
+
if (!directiveId) {
|
|
2361
|
+
return emitError(`ai-run: playbook ${playbookId} has no directives.`, null, pretty);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// Compute the informational phases up front — both stream and no-stream
|
|
2365
|
+
// modes share them.
|
|
2366
|
+
let governPhase, directPhase, lookPhase;
|
|
2367
|
+
try {
|
|
2368
|
+
governPhase = runner.govern(playbookId, directiveId, runOpts);
|
|
2369
|
+
directPhase = runner.direct(playbookId, directiveId);
|
|
2370
|
+
lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
2371
|
+
} catch (e) {
|
|
2372
|
+
process.stdout.write(JSON.stringify({ event: "error", reason: e.message, phase: "info" }) + "\n");
|
|
2373
|
+
process.exit(1);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
const governEvent = {
|
|
2377
|
+
phase: "govern",
|
|
2378
|
+
playbook_id: playbookId,
|
|
2379
|
+
directive_id: directiveId,
|
|
2380
|
+
jurisdiction_obligations: governPhase.jurisdiction_obligations || [],
|
|
2381
|
+
theater_fingerprints: governPhase.theater_fingerprints || [],
|
|
2382
|
+
framework_context: governPhase.framework_context || null,
|
|
2383
|
+
skill_preload: governPhase.skill_preload || [],
|
|
2384
|
+
};
|
|
2385
|
+
const directEvent = {
|
|
2386
|
+
phase: "direct",
|
|
2387
|
+
threat_context: directPhase.threat_context || null,
|
|
2388
|
+
rwep_threshold: directPhase.rwep_threshold || null,
|
|
2389
|
+
framework_lag_declaration: directPhase.framework_lag_declaration || null,
|
|
2390
|
+
skill_chain: directPhase.skill_chain || [],
|
|
2391
|
+
token_budget: directPhase.token_budget || null,
|
|
2392
|
+
};
|
|
2393
|
+
const lookEvent = {
|
|
2394
|
+
phase: "look",
|
|
2395
|
+
artifacts_required: (lookPhase.artifacts || []).filter(a => a.required),
|
|
2396
|
+
artifacts_optional: (lookPhase.artifacts || []).filter(a => !a.required),
|
|
2397
|
+
preconditions: lookPhase.preconditions || [],
|
|
2398
|
+
precondition_submission_shape: lookPhase.precondition_submission_shape || null,
|
|
2399
|
+
collection_scope: lookPhase.collection_scope || null,
|
|
2400
|
+
};
|
|
2401
|
+
const submissionShape = {
|
|
2402
|
+
observations: {},
|
|
2403
|
+
verdict: {},
|
|
2404
|
+
note: "Send back as {\"event\":\"evidence\",\"payload\":{\"observations\":{...},\"verdict\":{...}}}.",
|
|
2405
|
+
};
|
|
2406
|
+
|
|
2407
|
+
// ----- single-shot path -----
|
|
2408
|
+
if (args["no-stream"]) {
|
|
2409
|
+
// Read any pre-supplied evidence from stdin OR from --evidence flag.
|
|
2410
|
+
let payload = { observations: {}, verdict: {} };
|
|
2411
|
+
if (args.evidence) {
|
|
2412
|
+
try { payload = readEvidence(args.evidence); }
|
|
2413
|
+
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
2414
|
+
} else if (!process.stdin.isTTY) {
|
|
2415
|
+
// Drain stdin for any evidence event.
|
|
2416
|
+
try {
|
|
2417
|
+
const buf = fs.readFileSync(0, "utf8");
|
|
2418
|
+
if (buf.trim()) {
|
|
2419
|
+
// Accept either a bare submission object or a single evidence event.
|
|
2420
|
+
for (const line of buf.split(/\r?\n/)) {
|
|
2421
|
+
const t = line.trim();
|
|
2422
|
+
if (!t) continue;
|
|
2423
|
+
try {
|
|
2424
|
+
const parsed = JSON.parse(t);
|
|
2425
|
+
if (parsed && parsed.event === "evidence" && parsed.payload) {
|
|
2426
|
+
payload = parsed.payload;
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2429
|
+
// Bare submission fallback.
|
|
2430
|
+
if (parsed && (parsed.observations || parsed.artifacts || parsed.signal_overrides)) {
|
|
2431
|
+
payload = parsed.observations
|
|
2432
|
+
? parsed
|
|
2433
|
+
: { observations: { ...(parsed.artifacts || {}), ...(parsed.signal_overrides || {}) }, verdict: parsed.signals || {} };
|
|
2434
|
+
break;
|
|
2435
|
+
}
|
|
2436
|
+
} catch { /* skip non-JSON lines */ }
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
} catch { /* stdin empty / unreadable — fall through with empty payload */ }
|
|
2440
|
+
}
|
|
2441
|
+
const submission = buildSubmissionFromPayload(payload);
|
|
2442
|
+
let result;
|
|
2443
|
+
try {
|
|
2444
|
+
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
2445
|
+
} catch (e) {
|
|
2446
|
+
return emitError(`ai-run: runner threw: ${e.message}`, { playbook: playbookId }, pretty);
|
|
2447
|
+
}
|
|
2448
|
+
if (!result || result.ok === false) {
|
|
2449
|
+
process.stderr.write((pretty ? JSON.stringify(result || {}, null, 2) : JSON.stringify(result || {})) + "\n");
|
|
2450
|
+
process.exit(1);
|
|
2451
|
+
}
|
|
2452
|
+
emit({
|
|
2453
|
+
verb: "ai-run",
|
|
2454
|
+
mode: "no-stream",
|
|
2455
|
+
playbook_id: playbookId,
|
|
2456
|
+
directive_id: directiveId,
|
|
2457
|
+
govern: governEvent,
|
|
2458
|
+
direct: directEvent,
|
|
2459
|
+
look: lookEvent,
|
|
2460
|
+
detect: result.phases?.detect || null,
|
|
2461
|
+
analyze: result.phases?.analyze || null,
|
|
2462
|
+
validate: result.phases?.validate || null,
|
|
2463
|
+
close: result.phases?.close || null,
|
|
2464
|
+
session_id: result.session_id,
|
|
2465
|
+
evidence_hash: result.evidence_hash,
|
|
2466
|
+
}, pretty);
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// ----- streaming path -----
|
|
2471
|
+
// Emit info phases immediately, then wait for an evidence event on stdin.
|
|
2472
|
+
const writeLine = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
|
2473
|
+
writeLine(governEvent);
|
|
2474
|
+
writeLine(directEvent);
|
|
2475
|
+
writeLine(lookEvent);
|
|
2476
|
+
writeLine({ phase: "await_evidence", submission_shape: submissionShape });
|
|
2477
|
+
|
|
2478
|
+
let handled = false;
|
|
2479
|
+
let buf = "";
|
|
2480
|
+
|
|
2481
|
+
const handleLine = (line) => {
|
|
2482
|
+
if (handled) return;
|
|
2483
|
+
let parsed;
|
|
2484
|
+
try { parsed = JSON.parse(line); }
|
|
2485
|
+
catch (e) {
|
|
2486
|
+
writeLine({ event: "error", reason: `invalid JSON on stdin: ${e.message}`, line_preview: line.slice(0, 120) });
|
|
2487
|
+
process.exit(1);
|
|
2488
|
+
}
|
|
2489
|
+
if (!parsed || parsed.event !== "evidence" || !parsed.payload) {
|
|
2490
|
+
// Ignore non-evidence chatter so the host AI can interleave its own
|
|
2491
|
+
// status events; only an "evidence" event triggers phases 4-7.
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
handled = true;
|
|
2495
|
+
const submission = buildSubmissionFromPayload(parsed.payload);
|
|
2496
|
+
let result;
|
|
2497
|
+
try {
|
|
2498
|
+
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
2499
|
+
} catch (e) {
|
|
2500
|
+
writeLine({ event: "error", reason: `runner threw: ${e.message}` });
|
|
2501
|
+
process.exit(1);
|
|
2502
|
+
}
|
|
2503
|
+
if (!result || result.ok === false) {
|
|
2504
|
+
writeLine({ event: "error", reason: result?.reason || "runner returned ok:false", result });
|
|
2505
|
+
process.exit(1);
|
|
2506
|
+
}
|
|
2507
|
+
writeLine({ phase: "detect", ...result.phases?.detect });
|
|
2508
|
+
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
2509
|
+
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
2510
|
+
writeLine({ phase: "close", ...result.phases?.close });
|
|
2511
|
+
writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
|
|
2512
|
+
process.exit(0);
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
// Handle empty/closed stdin: emit a hint then exit cleanly so AI agents
|
|
2516
|
+
// calling ai-run without piping anything see a useful message rather than
|
|
2517
|
+
// a hung process.
|
|
2518
|
+
if (process.stdin.isTTY) {
|
|
2519
|
+
writeLine({ event: "error", reason: "ai-run streaming mode requires evidence on stdin; pipe {\"event\":\"evidence\",\"payload\":{...}} or use --no-stream." });
|
|
2520
|
+
process.exit(1);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
process.stdin.on("data", (chunk) => {
|
|
2524
|
+
buf += chunk.toString();
|
|
2525
|
+
let nl;
|
|
2526
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
2527
|
+
const line = buf.slice(0, nl).trim();
|
|
2528
|
+
buf = buf.slice(nl + 1);
|
|
2529
|
+
if (line) handleLine(line);
|
|
2530
|
+
}
|
|
2531
|
+
});
|
|
2532
|
+
process.stdin.on("end", () => {
|
|
2533
|
+
// Final flush — handle a trailing line without a newline.
|
|
2534
|
+
const tail = buf.trim();
|
|
2535
|
+
if (tail) handleLine(tail);
|
|
2536
|
+
if (!handled) {
|
|
2537
|
+
writeLine({ event: "error", reason: "stdin closed without an evidence event." });
|
|
2538
|
+
process.exit(1);
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
/**
|
|
2544
|
+
* Coerce a stdin payload into the runner submission shape. Accepts both the
|
|
2545
|
+
* v0.11.0 ai-run shape (observations + verdict) and the nested v0.10.x shape
|
|
2546
|
+
* (artifacts + signal_overrides + signals) for forward/back compat.
|
|
2547
|
+
*/
|
|
2548
|
+
function buildSubmissionFromPayload(payload) {
|
|
2549
|
+
if (!payload || typeof payload !== "object") return { artifacts: {}, signal_overrides: {}, signals: {} };
|
|
2550
|
+
// Nested v0.10.x shape passthrough.
|
|
2551
|
+
if (payload.artifacts || payload.signal_overrides || payload.signals) {
|
|
2552
|
+
return {
|
|
2553
|
+
artifacts: payload.artifacts || {},
|
|
2554
|
+
signal_overrides: payload.signal_overrides || {},
|
|
2555
|
+
signals: payload.signals || {},
|
|
2556
|
+
precondition_checks: payload.precondition_checks || undefined,
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
// v0.11.0 flat shape: observations becomes the artifacts+signal_overrides
|
|
2560
|
+
// union (the runner normalises both via normalizeSubmission), verdict
|
|
2561
|
+
// becomes signals.
|
|
2562
|
+
return {
|
|
2563
|
+
artifacts: payload.observations || {},
|
|
2564
|
+
signal_overrides: payload.observations || {},
|
|
2565
|
+
signals: payload.verdict || {},
|
|
2566
|
+
precondition_checks: payload.precondition_checks || undefined,
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* `ask "<question>"` — plain-English routing. Scores every playbook by token
|
|
2572
|
+
* overlap against domain.name + domain.attack_class + first sentence of
|
|
2573
|
+
* phases.direct.threat_context. Returns the top 5 matches with a confidence
|
|
2574
|
+
* score (matched tokens / total tokens).
|
|
2575
|
+
*/
|
|
2576
|
+
function cmdAsk(runner, args, runOpts, pretty) {
|
|
2577
|
+
const question = (args._ || []).join(" ").trim();
|
|
2578
|
+
if (!question) {
|
|
2579
|
+
return emitError("ask: usage: exceptd ask \"<plain-English question>\"", null, pretty);
|
|
2580
|
+
}
|
|
2581
|
+
const ids = runner.listPlaybooks();
|
|
2582
|
+
const q = question.toLowerCase();
|
|
2583
|
+
const tokens = q.split(/\W+/).filter(t => t.length > 3);
|
|
2584
|
+
const scored = [];
|
|
2585
|
+
for (const id of ids) {
|
|
2586
|
+
let pb;
|
|
2587
|
+
try { pb = runner.loadPlaybook(id); } catch { continue; }
|
|
2588
|
+
const threat = pb.phases?.direct?.threat_context || "";
|
|
2589
|
+
const firstSentence = threat.split(/(?<=[.!?])\s+/)[0] || "";
|
|
2590
|
+
const haystack = [
|
|
2591
|
+
pb.domain?.name || "",
|
|
2592
|
+
pb.domain?.attack_class || "",
|
|
2593
|
+
firstSentence,
|
|
2594
|
+
].join(" ").toLowerCase();
|
|
2595
|
+
const score = tokens.filter(t => haystack.includes(t)).length;
|
|
2596
|
+
scored.push({ id: pb._meta?.id || id, score });
|
|
2597
|
+
}
|
|
2598
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2599
|
+
const top = scored.filter(s => s.score > 0).slice(0, 5);
|
|
2600
|
+
|
|
2601
|
+
if (top.length === 0) {
|
|
2602
|
+
emit({
|
|
2603
|
+
verb: "ask",
|
|
2604
|
+
question,
|
|
2605
|
+
matched: [],
|
|
2606
|
+
hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
|
|
2607
|
+
}, pretty);
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
emit({
|
|
2612
|
+
verb: "ask",
|
|
2613
|
+
question,
|
|
2614
|
+
routed_to: top.map(t => t.id),
|
|
2615
|
+
confidence: top[0].score / Math.max(1, tokens.length),
|
|
2616
|
+
next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
|
|
2617
|
+
full_match_list: top,
|
|
2618
|
+
}, pretty);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* `ci [--all|--scope <type>]` — top-level CI gate. Effectively
|
|
2623
|
+
* `run --all --ci` packaged as a verb so .github/workflows lines are short.
|
|
2624
|
+
*
|
|
2625
|
+
* Exit codes:
|
|
2626
|
+
* 0 PASS — no detected findings, no rwep ≥ cap, no clock started (when
|
|
2627
|
+
* --block-on-jurisdiction-clock is set).
|
|
2628
|
+
* 2 FAIL — any of the above tripped.
|
|
2629
|
+
*/
|
|
2630
|
+
function cmdCi(runner, args, runOpts, pretty) {
|
|
2631
|
+
const scope = args.scope;
|
|
2632
|
+
const maxRwep = args["max-rwep"] !== undefined ? Number(args["max-rwep"]) : null;
|
|
2633
|
+
const blockOnClock = !!args["block-on-jurisdiction-clock"];
|
|
2634
|
+
|
|
2635
|
+
let ids;
|
|
2636
|
+
if (args.all) {
|
|
2637
|
+
ids = runner.listPlaybooks();
|
|
2638
|
+
} else if (scope) {
|
|
2639
|
+
ids = filterPlaybooksByScope(runner, scope);
|
|
2640
|
+
} else {
|
|
2641
|
+
const scopes = detectScopes();
|
|
2642
|
+
ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
|
2643
|
+
ids = [...new Set(ids)];
|
|
2644
|
+
}
|
|
2645
|
+
if (!ids || ids.length === 0) {
|
|
2646
|
+
return emitError("ci: no playbooks matched. Pass --all, --scope <type>, or run from a repo/Linux-host context.", null, pretty);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
|
|
2650
|
+
|
|
2651
|
+
// Evidence: --evidence <file> or --evidence-dir <dir>. Both produce a
|
|
2652
|
+
// bundle keyed by playbook id; ids without a key get an empty submission.
|
|
2653
|
+
let bundle = {};
|
|
2654
|
+
if (args.evidence) {
|
|
2655
|
+
try { bundle = readEvidence(args.evidence); }
|
|
2656
|
+
catch (e) { return emitError(`ci: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
2657
|
+
}
|
|
2658
|
+
if (args["evidence-dir"]) {
|
|
2659
|
+
const dir = args["evidence-dir"];
|
|
2660
|
+
if (!fs.existsSync(dir)) {
|
|
2661
|
+
return emitError(`ci: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
2662
|
+
}
|
|
2663
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
2664
|
+
try {
|
|
2665
|
+
bundle[f.replace(/\.json$/, "")] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2666
|
+
} catch (e) {
|
|
2667
|
+
return emitError(`ci: failed to parse evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
const results = [];
|
|
2673
|
+
let fail = false;
|
|
2674
|
+
let failReasons = [];
|
|
2675
|
+
|
|
2676
|
+
for (const id of ids) {
|
|
2677
|
+
let pb;
|
|
2678
|
+
try { pb = runner.loadPlaybook(id); }
|
|
2679
|
+
catch (e) { results.push({ playbook_id: id, ok: false, error: e.message }); fail = true; continue; }
|
|
2680
|
+
const directiveId = (pb.directives[0] && pb.directives[0].id);
|
|
2681
|
+
if (!directiveId) {
|
|
2682
|
+
results.push({ playbook_id: id, ok: false, error: "no directives" });
|
|
2683
|
+
fail = true;
|
|
2684
|
+
continue;
|
|
2685
|
+
}
|
|
2686
|
+
const submission = bundle[id] || {};
|
|
2687
|
+
const perOpts = { ...runOpts, session_id: sessionId };
|
|
2688
|
+
if (submission.precondition_checks) perOpts.precondition_checks = submission.precondition_checks;
|
|
2689
|
+
let result;
|
|
2690
|
+
try { result = runner.run(id, directiveId, submission, perOpts); }
|
|
2691
|
+
catch (e) { result = { ok: false, error: e.message, playbook_id: id }; }
|
|
2692
|
+
results.push(result);
|
|
2693
|
+
if (!result || result.ok === false) {
|
|
2694
|
+
fail = true;
|
|
2695
|
+
failReasons.push(`${id}: blocked (${result?.reason || result?.error || "unknown"})`);
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
const cls = result.phases?.detect?.classification;
|
|
2699
|
+
const rwepAdj = result.phases?.analyze?.rwep?.adjusted ?? 0;
|
|
2700
|
+
const cap = maxRwep !== null
|
|
2701
|
+
? maxRwep
|
|
2702
|
+
: (result.phases?.analyze?.rwep?.threshold?.escalate ?? 90);
|
|
2703
|
+
const clockStarted = (result.phases?.close?.notification_actions || [])
|
|
2704
|
+
.some(n => n && n.clock_started_at != null);
|
|
2705
|
+
if (cls === "detected") {
|
|
2706
|
+
fail = true;
|
|
2707
|
+
failReasons.push(`${id}: classification=detected`);
|
|
2708
|
+
}
|
|
2709
|
+
if (cls !== "not_detected" && cls !== "clean" && rwepAdj >= cap) {
|
|
2710
|
+
fail = true;
|
|
2711
|
+
failReasons.push(`${id}: rwep=${rwepAdj} >= cap=${cap} (classification=${cls})`);
|
|
2712
|
+
}
|
|
2713
|
+
if (blockOnClock && clockStarted) {
|
|
2714
|
+
fail = true;
|
|
2715
|
+
failReasons.push(`${id}: jurisdiction clock started`);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
|
|
2720
|
+
const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
|
|
2721
|
+
|
|
2722
|
+
emit({
|
|
2723
|
+
verb: "ci",
|
|
2724
|
+
session_id: sessionId,
|
|
2725
|
+
playbooks_run: ids,
|
|
2726
|
+
summary: {
|
|
2727
|
+
total: results.length,
|
|
2728
|
+
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
2729
|
+
inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
|
|
2730
|
+
not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
|
|
2731
|
+
blocked: results.filter(r => r && r.ok === false).length,
|
|
2732
|
+
max_rwep_observed: maxRwepObserved,
|
|
2733
|
+
jurisdiction_clocks_started: results
|
|
2734
|
+
.flatMap(r => r.phases?.close?.notification_actions || [])
|
|
2735
|
+
.filter(n => n && n.clock_started_at != null).length,
|
|
2736
|
+
verdict: fail ? "FAIL" : "PASS",
|
|
2737
|
+
fail_reasons: failReasons,
|
|
2738
|
+
},
|
|
2739
|
+
results,
|
|
2740
|
+
}, pretty);
|
|
2741
|
+
if (fail) {
|
|
2742
|
+
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
2743
|
+
process.exit(2);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
1016
2747
|
if (require.main === module) main();
|
|
1017
2748
|
|
|
1018
2749
|
module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
|