@blamejs/exceptd-skills 0.10.3 → 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/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
- "plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations", "attest",
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
- Discovery:
122
- path Print absolute path to the installed package.
123
- Point your AI assistant here:
124
- $(exceptd path)/AGENTS.md
125
- $(exceptd path)/data/_indexes/summary-cards.json
126
-
127
- External data:
128
- prefetch [args] Warm local cache of upstream artifacts
129
- (KEV / NVD / EPSS / IETF / GitHub releases).
130
- Try: exceptd prefetch --no-network --quiet
131
- refresh [args] Refresh against cache + apply upserts.
132
- Try: exceptd refresh --from-cache --swarm
133
-
134
- Build / verify:
135
- build-indexes [args] Regenerate data/_indexes/*.json.
136
- Try: exceptd build-indexes --changed
137
- verify Verify every skill's Ed25519 signature.
138
-
139
- Analyst:
140
- scan Scan environment for findings.
141
- dispatch Scan then route findings to skills.
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
- currency Skill currency report.
144
- report [format] Compliance / executive / technical report.
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
- npx @blamejs/exceptd-skills path
188
- npx @blamejs/exceptd-skills prefetch
189
- npx @blamejs/exceptd-skills validate-cves --from-cache --no-fail
190
- npx @blamejs/exceptd-skills skill kernel-lpe-triage
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
- printHelp();
279
+ printWelcome();
201
280
  process.exit(0);
202
281
  }
203
282
  const cmd = argv[0];
@@ -219,6 +298,15 @@ 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
  }
@@ -340,7 +428,8 @@ function dispatchPlaybook(cmd, argv) {
340
428
 
341
429
  const args = parseArgs(argv, {
342
430
  bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
343
- "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack"],
431
+ "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
432
+ "force-overwrite", "no-stream", "block-on-jurisdiction-clock"],
344
433
  multi: ["playbook", "format"],
345
434
  });
346
435
  const pretty = !!args.pretty;
@@ -349,8 +438,28 @@ function dispatchPlaybook(cmd, argv) {
349
438
  forceStale: !!args["force-stale"],
350
439
  };
351
440
  if (args["session-id"]) runOpts.session_id = args["session-id"];
352
- if (args["session-key"]) runOpts.session_key = args["session-key"];
353
- if (args.mode) runOpts.mode = args.mode;
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
+ }
354
463
  // Multi-operator teams need attestations bound to a specific human or
355
464
  // service identity. --operator <name> persists into the attestation file
356
465
  // for audit-trail accountability. Free-form string; no validation.
@@ -380,6 +489,15 @@ function dispatchPlaybook(cmd, argv) {
380
489
  case "reattest": return cmdReattest(runner, args, runOpts, pretty);
381
490
  case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
382
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);
383
501
  }
384
502
  } catch (e) {
385
503
  emitError(e.message, { verb: cmd }, pretty);
@@ -517,10 +635,261 @@ Subverbs:
517
635
  Reports tamper status per attestation file.
518
636
 
519
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[].`,
520
721
  };
521
722
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
522
723
  }
523
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
+
524
893
  function cmdPlan(runner, args, runOpts, pretty) {
525
894
  let playbookIds = args.playbook
526
895
  ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
@@ -751,27 +1120,41 @@ function cmdRun(runner, args, runOpts, pretty) {
751
1120
 
752
1121
  // Persist attestation for reattest cycles when the run succeeded.
753
1122
  if (result && result.ok && result.session_id) {
754
- try {
755
- const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
756
- fs.mkdirSync(dir, { recursive: true });
757
- const attestation = {
758
- session_id: result.session_id,
759
- playbook_id: result.playbook_id,
760
- directive_id: result.directive_id,
761
- evidence_hash: result.evidence_hash,
762
- operator: runOpts.operator || null,
763
- operator_consent: runOpts.operator_consent || null,
764
- submission,
765
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
766
- captured_at: new Date().toISOString(),
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",
767
1146
  };
768
- fs.writeFileSync(path.join(dir, "attestation.json"), JSON.stringify(attestation, null, 2));
769
- // Feature #27: Ed25519-sign the attestation if .keys/private.pem exists
770
- // (signing infrastructure is already shared with skill signing). Without
771
- // a private key, write an unsigned `.sig` placeholder so tooling can tell
772
- // the difference between "unsigned" and "tampered post-hoc".
773
- maybeSignAttestation(path.join(dir, "attestation.json"));
774
- } catch { /* non-fatal attestation persistence is best-effort */ }
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
+ }
775
1158
  }
776
1159
 
777
1160
  if (result && result.ok === false) {
@@ -899,23 +1282,26 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
899
1282
 
900
1283
  // Persist per-playbook attestation under the shared session.
901
1284
  if (result && result.ok) {
902
- try {
903
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
904
- fs.mkdirSync(dir, { recursive: true });
905
- const filePath = path.join(dir, `${id}.json`);
906
- fs.writeFileSync(filePath, JSON.stringify({
907
- session_id: sessionId,
908
- playbook_id: id,
909
- directive_id: directiveId,
910
- evidence_hash: result.evidence_hash,
911
- operator: perRunOpts.operator || null,
912
- operator_consent: perRunOpts.operator_consent || null,
913
- submission,
914
- run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
915
- captured_at: new Date().toISOString(),
916
- }, null, 2));
917
- maybeSignAttestation(filePath);
918
- } catch { /* non-fatal */ }
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
+ }
919
1305
  }
920
1306
  results.push(result);
921
1307
  }
@@ -971,7 +1357,7 @@ function cmdIngest(runner, args, runOpts, pretty) {
971
1357
 
972
1358
  if (result && result.ok && result.session_id) {
973
1359
  try {
974
- const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
1360
+ const dir = path.join(resolveAttestationRoot(runOpts), result.session_id);
975
1361
  fs.mkdirSync(dir, { recursive: true });
976
1362
  fs.writeFileSync(
977
1363
  path.join(dir, "attestation.json"),
@@ -995,6 +1381,113 @@ function cmdIngest(runner, args, runOpts, pretty) {
995
1381
  emit(result, pretty);
996
1382
  }
997
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
+
998
1491
  /**
999
1492
  * Ed25519-sign an attestation file when .keys/private.pem is available
1000
1493
  * (matches lib/sign.js convention for skill signing). Writes a sidecar
@@ -1010,6 +1503,19 @@ function maybeSignAttestation(filePath) {
1010
1503
  const sigPath = filePath + ".sig";
1011
1504
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
1012
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
+ }
1013
1519
  try {
1014
1520
  if (fs.existsSync(privKeyPath)) {
1015
1521
  const privateKey = fs.readFileSync(privKeyPath, "utf8");
@@ -1037,21 +1543,49 @@ function maybeSignAttestation(filePath) {
1037
1543
  } catch { /* non-fatal — signing failure shouldn't block the run */ }
1038
1544
  }
1039
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
+
1040
1560
  /**
1041
1561
  * Find the latest attestation file under .exceptd/attestations/.
1042
1562
  * Filters: optional playbook ID and optional "since" ISO timestamp.
1043
1563
  * Returns { sessionId, playbookId, file, parsed } or null.
1044
1564
  */
1045
1565
  function findLatestAttestation(opts = {}) {
1046
- const root = path.join(process.cwd(), ".exceptd", "attestations");
1047
- if (!fs.existsSync(root)) return null;
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;
1048
1583
  const sessions = fs.readdirSync(root, { withFileTypes: true })
1049
1584
  .filter(d => d.isDirectory())
1050
1585
  .map(d => d.name);
1051
- const candidates = [];
1052
1586
  for (const sid of sessions) {
1053
1587
  const sdir = path.join(root, sid);
1054
- 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"))) {
1055
1589
  try {
1056
1590
  const p = path.join(sdir, f);
1057
1591
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
@@ -1062,8 +1596,6 @@ function findLatestAttestation(opts = {}) {
1062
1596
  } catch { /* skip malformed */ }
1063
1597
  }
1064
1598
  }
1065
- candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
1066
- return candidates[0] || null;
1067
1599
  }
1068
1600
 
1069
1601
  function cmdReattest(runner, args, runOpts, pretty) {
@@ -1081,10 +1613,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
1081
1613
  attFile = found.file;
1082
1614
  }
1083
1615
  if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
1084
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
1616
+ const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
1085
1617
  if (!attFile) attFile = path.join(dir, "attestation.json");
1086
1618
  if (!fs.existsSync(attFile)) {
1087
- return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
1619
+ return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
1088
1620
  }
1089
1621
  let prior;
1090
1622
  try {
@@ -1170,14 +1702,18 @@ function cmdAttest(runner, args, runOpts, pretty) {
1170
1702
  const subverb = args._[0];
1171
1703
  const sessionId = args._[1];
1172
1704
  if (!subverb) {
1173
- return emitError("attest: missing subverb. Usage: attest export|verify|show <session-id>", null, pretty);
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);
1174
1710
  }
1175
1711
  if (!sessionId) {
1176
1712
  return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
1177
1713
  }
1178
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
1179
- if (!fs.existsSync(dir)) {
1180
- return emitError(`attest ${subverb}: no session dir at ${path.relative(process.cwd(), dir)}`, { session_id: sessionId }, pretty);
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);
1181
1717
  }
1182
1718
 
1183
1719
  const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
@@ -1191,6 +1727,46 @@ function cmdAttest(runner, args, runOpts, pretty) {
1191
1727
  return;
1192
1728
  }
1193
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
+
1194
1770
  if (subverb === "verify") {
1195
1771
  const crypto = require("crypto");
1196
1772
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
@@ -1267,33 +1843,483 @@ function cmdAttest(runner, args, runOpts, pretty) {
1267
1843
  return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
1268
1844
  }
1269
1845
 
1270
- function cmdListAttestations(runner, args, runOpts, pretty) {
1271
- const root = path.join(process.cwd(), ".exceptd", "attestations");
1272
- if (!fs.existsSync(root)) {
1273
- return emit({ ok: true, attestations: [], note: `No attestations directory at ${path.relative(process.cwd(), root)}` }, pretty);
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++; }
1274
1866
  }
1275
- const sessions = fs.readdirSync(root, { withFileTypes: true })
1276
- .filter(d => d.isDirectory())
1277
- .map(d => d.name);
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
+ }
1278
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")];
1279
2296
  const entries = [];
1280
- for (const sid of sessions) {
1281
- const sdir = path.join(root, sid);
1282
- const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json"));
1283
- for (const f of files) {
1284
- try {
1285
- const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
1286
- // Apply --playbook filter if supplied.
1287
- if (args.playbook && j.playbook_id !== args.playbook) continue;
1288
- entries.push({
1289
- session_id: sid,
1290
- playbook_id: j.playbook_id,
1291
- directive_id: j.directive_id,
1292
- evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
1293
- captured_at: j.captured_at || null,
1294
- file: path.relative(process.cwd(), path.join(sdir, f)),
1295
- });
1296
- } catch { /* skip malformed */ }
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
+ }
1297
2323
  }
1298
2324
  }
1299
2325
  entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
@@ -1301,10 +2327,423 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
1301
2327
  ok: true,
1302
2328
  attestations: entries,
1303
2329
  count: entries.length,
1304
- filter: { playbook: args.playbook || null },
2330
+ filter: { playbook: args.playbook || null, since: args.since || null },
2331
+ roots_searched: [...seenRoots],
1305
2332
  }, pretty);
1306
2333
  }
1307
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
+
1308
2747
  if (require.main === module) main();
1309
2748
 
1310
2749
  module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };