@blamejs/exceptd-skills 0.9.5 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,896 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Playbook runner — executes the seven-phase investigation contract defined in
5
+ * lib/schemas/playbook.schema.json:
6
+ *
7
+ * 1. govern exceptd. Loads GRC context: jurisdiction obligations, theater
8
+ * fingerprints, framework gaps, skills to preload. Sets the
9
+ * compliance lens before any investigation runs.
10
+ * 2. direct exceptd. Scopes the investigation: threat context with current
11
+ * CVE/TTP citations, RWEP thresholds, framework lag declaration,
12
+ * skill chain, token budget.
13
+ * 3. look host AI. Collects typed artifacts (logs/files/processes/
14
+ * network/etc.) per artifact spec, with air-gap fallbacks.
15
+ * 4. detect host AI. Evaluates artifacts against typed indicators, applies
16
+ * false-positive profile, classifies as detected | inconclusive
17
+ * | not_detected.
18
+ * 5. analyze exceptd. Computes RWEP from rwep_inputs, scores blast radius,
19
+ * runs compliance_theater_check, generates framework_gap_mapping
20
+ * entries, fires escalation_criteria.
21
+ * 6. validate exceptd. Picks remediation_path by priority + preconditions,
22
+ * emits validation_tests, renders residual_risk_statement, lists
23
+ * evidence_requirements, computes regression schedule.
24
+ * 7. close exceptd. Closes the GRC loop: assembles evidence_package
25
+ * (signed by default), drafts learning_loop lesson, computes
26
+ * notification_actions deadlines from govern.jurisdiction_obligations
27
+ * clock_starts + window_hours, evaluates exception_generation
28
+ * trigger and renders auditor-ready language, finalizes
29
+ * regression_schedule.next_run.
30
+ *
31
+ * Currency gate: _meta.threat_currency_score < 50 hard-blocks execution unless
32
+ * the caller passes { forceStale: true }. Below 70 warns. The schema declares
33
+ * the score; the runner enforces.
34
+ *
35
+ * Preconditions: each _meta.preconditions entry has on_fail = halt|warn|skip_phase.
36
+ * Engine evaluates the (host AI-supplied) check value and reacts accordingly.
37
+ *
38
+ * Mutex: an in-process Set tracks active playbook runs. Engine refuses to start
39
+ * a playbook whose _meta.mutex intersects active runs.
40
+ *
41
+ * feeds_into: close() returns a list of downstream playbook IDs whose
42
+ * conditions are satisfied by this run's finding — the agent decides whether
43
+ * to chain into them.
44
+ */
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+ const crypto = require('crypto');
49
+
50
+ const xref = require('./cross-ref-api');
51
+
52
+ const ROOT = path.join(__dirname, '..');
53
+ const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data', 'playbooks');
54
+
55
+ // In-process mutex tracker. Survives only the current Node process.
56
+ // Persistent cross-process coordination is out of scope — that's for the GRC
57
+ // platform integration, not the runner.
58
+ const _activeRuns = new Set();
59
+
60
+ // --- catalog access ---
61
+
62
+ function listPlaybooks() {
63
+ if (!fs.existsSync(PLAYBOOK_DIR)) return [];
64
+ return fs.readdirSync(PLAYBOOK_DIR)
65
+ .filter(f => f.endsWith('.json') && !f.startsWith('_'))
66
+ .map(f => f.replace(/\.json$/, ''));
67
+ }
68
+
69
+ function loadPlaybook(playbookId) {
70
+ const p = path.join(PLAYBOOK_DIR, `${playbookId}.json`);
71
+ if (!fs.existsSync(p)) throw new Error(`Playbook not found: ${playbookId} (expected ${p})`);
72
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
73
+ }
74
+
75
+ function findDirective(playbook, directiveId) {
76
+ const d = playbook.directives.find(x => x.id === directiveId);
77
+ if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
78
+ return d;
79
+ }
80
+
81
+ // --- phase-resolution: merge playbook.phases with directive.phase_overrides ---
82
+
83
+ function resolvedPhase(playbook, directiveId, phaseName) {
84
+ const base = playbook.phases[phaseName] || {};
85
+ const directive = playbook.directives.find(x => x.id === directiveId);
86
+ const override = directive?.phase_overrides?.[phaseName];
87
+ if (!override) return base;
88
+ return deepMerge(base, override);
89
+ }
90
+
91
+ function deepMerge(a, b) {
92
+ if (b === null || b === undefined) return a;
93
+ if (typeof b !== 'object' || Array.isArray(b)) return b;
94
+ const out = { ...a };
95
+ for (const [k, v] of Object.entries(b)) {
96
+ out[k] = (k in out) ? deepMerge(out[k], v) : v;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // --- pre-flight: currency + preconditions + mutex ---
102
+
103
+ function preflight(playbook, runOpts = {}) {
104
+ const issues = [];
105
+ const meta = playbook._meta;
106
+
107
+ // 1. Currency gate
108
+ const score = meta.threat_currency_score;
109
+ if (score < 50 && !runOpts.forceStale) {
110
+ return {
111
+ ok: false,
112
+ blocked_by: 'currency',
113
+ reason: `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`,
114
+ issues
115
+ };
116
+ }
117
+ if (score < 70) {
118
+ issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
119
+ }
120
+
121
+ // 2. Preconditions
122
+ for (const pc of meta.preconditions || []) {
123
+ const submitted = runOpts.precondition_checks?.[pc.id];
124
+ if (submitted === undefined) {
125
+ const submission_hint = `Submit precondition_checks in your evidence JSON, e.g. { "precondition_checks": { "${pc.id}": true } }. The runner lifts this into runOpts before the gate evaluates.`;
126
+ issues.push({ kind: 'precondition_unverified', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint });
127
+ if (pc.on_fail === 'halt') {
128
+ return {
129
+ ok: false,
130
+ blocked_by: 'precondition',
131
+ reason: `Precondition ${pc.id} (${pc.check}) not verified by host AI; on_fail=halt.`,
132
+ remediation: submission_hint,
133
+ issues
134
+ };
135
+ }
136
+ continue;
137
+ }
138
+ if (submitted === false) {
139
+ if (pc.on_fail === 'halt') {
140
+ return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
141
+ }
142
+ issues.push({ kind: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn', id: pc.id, message: pc.description });
143
+ }
144
+ }
145
+
146
+ // 3. Mutex
147
+ for (const conflictId of meta.mutex || []) {
148
+ if (_activeRuns.has(conflictId)) {
149
+ return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict: playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
150
+ }
151
+ }
152
+
153
+ return { ok: true, issues };
154
+ }
155
+
156
+ // --- phase 1: govern ---
157
+
158
+ /**
159
+ * Load GRC context for the agent. Returns jurisdiction obligations (with
160
+ * window_hours + clock_starts so close() can compute deadlines later), theater
161
+ * fingerprints, framework gap summary, and skills to preload.
162
+ */
163
+ function govern(playbookId, directiveId, runOpts = {}) {
164
+ const playbook = loadPlaybook(playbookId);
165
+ const g = resolvedPhase(playbook, directiveId, 'govern');
166
+ return {
167
+ phase: 'govern',
168
+ playbook_id: playbookId,
169
+ directive_id: directiveId,
170
+ domain: playbook.domain,
171
+ threat_currency_score: playbook._meta.threat_currency_score,
172
+ last_threat_review: playbook._meta.last_threat_review,
173
+ air_gap_mode: !!playbook._meta.air_gap_mode || !!runOpts.airGap,
174
+ jurisdiction_obligations: g.jurisdiction_obligations || [],
175
+ theater_fingerprints: g.theater_fingerprints || [],
176
+ framework_context: g.framework_context || {},
177
+ skill_preload: g.skill_preload || []
178
+ };
179
+ }
180
+
181
+ // --- phase 2: direct ---
182
+
183
+ function direct(playbookId, directiveId) {
184
+ const playbook = loadPlaybook(playbookId);
185
+ const d = resolvedPhase(playbook, directiveId, 'direct');
186
+ return {
187
+ phase: 'direct',
188
+ playbook_id: playbookId,
189
+ directive_id: directiveId,
190
+ threat_context: d.threat_context,
191
+ rwep_threshold: d.rwep_threshold,
192
+ framework_lag_declaration: d.framework_lag_declaration,
193
+ skill_chain: d.skill_chain || [],
194
+ token_budget: d.token_budget || {}
195
+ };
196
+ }
197
+
198
+ // --- phase 3: look (engine emits, agent executes) ---
199
+
200
+ function look(playbookId, directiveId, runOpts = {}) {
201
+ const playbook = loadPlaybook(playbookId);
202
+ const l = resolvedPhase(playbook, directiveId, 'look');
203
+ const airGap = !!playbook._meta.air_gap_mode || !!runOpts.airGap;
204
+ return {
205
+ phase: 'look',
206
+ playbook_id: playbookId,
207
+ directive_id: directiveId,
208
+ air_gap_mode: airGap,
209
+ // Preconditions are surfaced here so the host AI can verify them with its
210
+ // own probes (Bash:test -f /proc/version, etc.) and declare the results
211
+ // back through submission.precondition_checks. Without this list, the AI
212
+ // is blind to the gate and run() will halt with a precondition_unverified
213
+ // failure the AI can't diagnose. See AGENTS.md Hard Rule context.
214
+ preconditions: (playbook._meta.preconditions || []).map(pc => ({
215
+ id: pc.id,
216
+ description: pc.description,
217
+ check: pc.check,
218
+ on_fail: pc.on_fail
219
+ })),
220
+ precondition_submission_shape: {
221
+ hint: 'Include precondition_checks: { "<precondition-id>": true|false } in your submission JSON. The runner lifts it into runOpts before evaluating the gate.',
222
+ example: { precondition_checks: { 'linux-platform': true, 'uname-available': true } }
223
+ },
224
+ artifacts: (l.artifacts || []).map(a => ({
225
+ ...a,
226
+ // Surface the air-gap alternative as the primary source when air_gap_mode
227
+ // is active, so the agent doesn't accidentally hit the network.
228
+ source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
229
+ _original_source: a.source
230
+ })),
231
+ collection_scope: l.collection_scope,
232
+ environment_assumptions: l.environment_assumptions || [],
233
+ fallback_if_unavailable: l.fallback_if_unavailable || []
234
+ };
235
+ }
236
+
237
+ // --- phase 4: detect ---
238
+
239
+ /**
240
+ * Evaluate artifacts the agent submitted against the playbook's typed
241
+ * indicators. Returns a per-indicator hit/miss/inconclusive verdict plus a
242
+ * minimum_signal classification (detected | inconclusive | not_detected).
243
+ *
244
+ * The agent submits `artifacts` as { artifact_id: { value, captured: true|false, reason? } }
245
+ * and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
246
+ * record an indicator outcome the agent computed using its own pattern matching.
247
+ */
248
+ function detect(playbookId, directiveId, agentSubmission = {}) {
249
+ const playbook = loadPlaybook(playbookId);
250
+ const det = resolvedPhase(playbook, directiveId, 'detect');
251
+ const artifacts = agentSubmission.artifacts || {};
252
+ const overrides = agentSubmission.signal_overrides || {};
253
+
254
+ const indicatorResults = (det.indicators || []).map(ind => {
255
+ const override = overrides[ind.id];
256
+ let verdict;
257
+ if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
258
+ verdict = override;
259
+ } else {
260
+ // Without an explicit override, treat any captured artifact as evidence
261
+ // the indicator could be evaluated. Mark inconclusive if no related
262
+ // artifact was captured — engine doesn't pattern-match raw artifact
263
+ // content; the host AI is responsible for that.
264
+ const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
265
+ verdict = anyCaptured ? 'inconclusive' : 'inconclusive';
266
+ }
267
+ return {
268
+ id: ind.id, type: ind.type, confidence: ind.confidence,
269
+ deterministic: ind.deterministic, atlas_ref: ind.atlas_ref || null,
270
+ attack_ref: ind.attack_ref || null, verdict
271
+ };
272
+ });
273
+
274
+ // false-positive profile — engine highlights which FP tests the agent
275
+ // should still run against any indicator the agent reported as 'hit'.
276
+ const fpChecksRequired = (det.false_positive_profile || []).filter(fp =>
277
+ indicatorResults.find(r => r.id === fp.indicator_id && r.verdict === 'hit')
278
+ );
279
+
280
+ const hits = indicatorResults.filter(r => r.verdict === 'hit');
281
+ const hasDeterministicHit = hits.some(r => r.deterministic);
282
+ const hasHighConfHit = hits.some(r => r.confidence === 'high' || r.confidence === 'deterministic');
283
+
284
+ // Agent override: if signals.detection_classification is explicitly set to
285
+ // one of the four legal values, honor it. Engine computes its own
286
+ // classification as a fallback. Use the override when the agent has run the
287
+ // full false_positive_profile checks and reached an explicit verdict —
288
+ // engine-computed classification can't represent "I saw the indicators and
289
+ // confirmed they're all benign" without this override.
290
+ const override = (agentSubmission.signals && agentSubmission.signals.detection_classification);
291
+ const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
292
+
293
+ let classification;
294
+ if (override && validOverrides.has(override)) {
295
+ classification = override === 'clean' ? 'not_detected' : override;
296
+ } else if (hasDeterministicHit || hasHighConfHit) {
297
+ classification = 'detected';
298
+ } else if (hits.length === 0 && indicatorResults.every(r => r.verdict === 'miss')) {
299
+ classification = 'not_detected';
300
+ } else {
301
+ classification = 'inconclusive';
302
+ }
303
+
304
+ return {
305
+ phase: 'detect',
306
+ playbook_id: playbookId,
307
+ directive_id: directiveId,
308
+ indicators: indicatorResults,
309
+ false_positive_checks_required: fpChecksRequired,
310
+ classification,
311
+ minimum_signal_basis: det.minimum_signal?.[classification === 'detected' ? 'detected' : classification === 'not_detected' ? 'not_detected' : 'inconclusive']
312
+ };
313
+ }
314
+
315
+ // --- phase 5: analyze ---
316
+
317
+ /**
318
+ * RWEP composition + blast-radius scoring + theater check + framework gap
319
+ * mapping + escalation evaluation. Inputs are the detect result + any
320
+ * agent-submitted signal_values (e.g. blast_radius classification).
321
+ */
322
+ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
323
+ const playbook = loadPlaybook(playbookId);
324
+ const an = resolvedPhase(playbook, directiveId, 'analyze');
325
+ const directive = findDirective(playbook, directiveId);
326
+
327
+ // Match catalogued CVEs from the domain.cve_refs list. The agent submits
328
+ // signal values; engine joins to the catalog for RWEP context.
329
+ const cveRefs = playbook.domain.cve_refs || [];
330
+ const matchedCves = cveRefs
331
+ .map(id => xref.byCve(id))
332
+ .filter(r => r.found);
333
+
334
+ // RWEP composition: start from the catalogue's per-CVE rwep_score (already
335
+ // baked from KEV + PoC + AI-disc + active-exploitation + blast-radius), then
336
+ // adjust by playbook's rwep_inputs based on detect hits + agent signals.
337
+ const baseRwep = matchedCves.length ? Math.max(...matchedCves.map(c => c.rwep_score)) : 0;
338
+ let adjustedRwep = baseRwep;
339
+ const rwepBreakdown = [];
340
+ for (const input of an.rwep_inputs || []) {
341
+ const indicator = detectResult.indicators?.find(i => i.id === input.signal_id);
342
+ const fired = indicator?.verdict === 'hit' || agentSignals[input.signal_id] === true;
343
+ if (fired) {
344
+ adjustedRwep += input.weight;
345
+ rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: input.weight, fired: true });
346
+ } else {
347
+ rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false });
348
+ }
349
+ }
350
+ adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
351
+
352
+ // blast_radius
353
+ const blastRubric = an.blast_radius_model?.scoring_rubric || [];
354
+ const blastRadiusScore = agentSignals.blast_radius_score || (blastRubric[0]?.blast_radius_score ?? null);
355
+
356
+ // compliance_theater_check — engine surfaces the test; agent runs it; we
357
+ // accept the verdict in agentSignals.theater_verdict. When agent didn't
358
+ // submit a verdict but the detect phase reached a clear classification,
359
+ // derive one rather than leaving the field stuck in 'pending_agent_run':
360
+ // detect.classification = not_detected → theater_verdict = clear
361
+ // detect.classification = detected → theater_verdict = pending_agent_run
362
+ // (agent still must run reality_test)
363
+ // detect.classification = inconclusive → theater_verdict = pending_agent_run
364
+ // Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
365
+ let theaterVerdict = agentSignals.theater_verdict;
366
+ if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
367
+ if (!theaterVerdict && an.compliance_theater_check) {
368
+ const cls = detectResult.classification;
369
+ theaterVerdict = cls === 'not_detected' ? 'clear' : 'pending_agent_run';
370
+ }
371
+ theaterVerdict = theaterVerdict || (an.compliance_theater_check ? 'pending_agent_run' : null);
372
+
373
+ // framework_gap_mapping — engine emits the mapping verbatim; analyze does
374
+ // not compute new gaps here, just attaches the playbook-declared ones.
375
+ const frameworkGaps = an.framework_gap_mapping || [];
376
+
377
+ // escalation criteria
378
+ const escalations = [];
379
+ for (const ec of an.escalation_criteria || []) {
380
+ if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals }, playbook)) {
381
+ escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
382
+ }
383
+ }
384
+
385
+ return {
386
+ phase: 'analyze',
387
+ playbook_id: playbookId,
388
+ directive_id: directiveId,
389
+ // Hard Rule #1 (AGENTS.md): every CVE reference must carry CVSS + KEV +
390
+ // PoC + AI-discovery + active-exploitation + patch/live-patch availability.
391
+ // Pull every required field from the catalog entry; null is only emitted
392
+ // when the catalog itself lacks the value, never when we just forgot to
393
+ // forward it. EPSS is included because validate-cves --live populates it.
394
+ matched_cves: matchedCves.map(c => ({
395
+ cve_id: c.cve_id,
396
+ rwep: c.rwep_score,
397
+ cvss_score: c.entry?.cvss_score ?? null,
398
+ cvss_vector: c.entry?.cvss_vector ?? null,
399
+ cisa_kev: c.cisa_kev,
400
+ cisa_kev_date: c.entry?.cisa_kev_date ?? null,
401
+ cisa_kev_due_date: c.entry?.cisa_kev_due_date ?? null,
402
+ poc_available: c.entry?.poc_available ?? null,
403
+ ai_discovered: c.ai_discovered,
404
+ ai_assisted_weaponization: c.entry?.ai_assisted_weaponization ?? null,
405
+ active_exploitation: c.active_exploitation,
406
+ patch_available: c.entry?.patch_available ?? null,
407
+ patch_required_reboot: c.entry?.patch_required_reboot ?? null,
408
+ live_patch_available: c.entry?.live_patch_available ?? null,
409
+ epss_score: c.entry?.epss_score ?? null,
410
+ epss_date: c.entry?.epss_date ?? null,
411
+ atlas_refs: c.atlas_refs,
412
+ attack_refs: c.attack_refs,
413
+ affected_versions: c.entry?.affected_versions ?? null,
414
+ })),
415
+ rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null },
416
+ blast_radius_score: blastRadiusScore,
417
+ blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
418
+ compliance_theater_check: {
419
+ claim: an.compliance_theater_check?.claim,
420
+ audit_evidence: an.compliance_theater_check?.audit_evidence,
421
+ reality_test: an.compliance_theater_check?.reality_test,
422
+ verdict: theaterVerdict,
423
+ verdict_text: theaterVerdict === 'theater' ? an.compliance_theater_check?.theater_verdict_if_gap : null
424
+ },
425
+ framework_gap_mapping: frameworkGaps,
426
+ escalations
427
+ };
428
+ }
429
+
430
+ // --- phase 6: validate ---
431
+
432
+ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
433
+ const playbook = loadPlaybook(playbookId);
434
+ const v = resolvedPhase(playbook, directiveId, 'validate');
435
+
436
+ // Pick the highest-priority remediation_path whose preconditions are all
437
+ // either satisfied by agentSignals or marked unverified=allow.
438
+ const paths = (v.remediation_paths || []).slice().sort((a, b) => a.priority - b.priority);
439
+ let selected = null;
440
+ const considered = [];
441
+ for (const p of paths) {
442
+ const pcResult = (p.preconditions || []).map(expr => ({
443
+ expr,
444
+ satisfied: evalCondition(expr, agentSignals, playbook),
445
+ submitted: agentSignals[expressionKey(expr)] !== undefined
446
+ }));
447
+ const allSatisfied = pcResult.every(x => x.satisfied);
448
+ considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, preconditions: pcResult });
449
+ if (allSatisfied && !selected) selected = p;
450
+ }
451
+ // Always at least propose the highest-priority path even if preconditions
452
+ // weren't verified — the agent can surface that to the operator.
453
+ if (!selected && paths.length) selected = paths[0];
454
+
455
+ // Compute regression schedule next_run (engine sets a single soonest run).
456
+ const triggers = v.regression_trigger || [];
457
+ const nextRun = computeRegressionNextRun(triggers);
458
+
459
+ return {
460
+ phase: 'validate',
461
+ playbook_id: playbookId,
462
+ directive_id: directiveId,
463
+ selected_remediation: selected,
464
+ remediation_options_considered: considered,
465
+ validation_tests: v.validation_tests || [],
466
+ residual_risk_statement: v.residual_risk_statement || null,
467
+ evidence_requirements: v.evidence_requirements || [],
468
+ regression_trigger: triggers,
469
+ regression_next_run: nextRun
470
+ };
471
+ }
472
+
473
+ function computeRegressionNextRun(triggers) {
474
+ const now = new Date();
475
+ let soonest = null;
476
+ for (const t of triggers) {
477
+ const m = (t.interval || '').match(/^(\d+)d$/);
478
+ if (m) {
479
+ const d = new Date(now.getTime() + parseInt(m[1], 10) * 24 * 3600 * 1000);
480
+ if (!soonest || d < soonest) soonest = d;
481
+ }
482
+ }
483
+ return soonest ? soonest.toISOString() : null;
484
+ }
485
+
486
+ // --- phase 7: close ---
487
+
488
+ /**
489
+ * Assemble the closure artifacts:
490
+ * - evidence_package (CSAF-2.0 shaped if requested; signed if signing key present)
491
+ * - learning_loop lesson template populated with current finding context
492
+ * - notification_actions with computed ISO 8601 deadlines from clock_starts + window_hours
493
+ * - exception_generation auditor-ready language if trigger fires
494
+ * - regression_schedule.next_run from validate.regression_next_run
495
+ * - feeds_into chaining suggestions
496
+ */
497
+ function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
498
+ const playbook = loadPlaybook(playbookId);
499
+ const c = resolvedPhase(playbook, directiveId, 'close');
500
+ const g = resolvedPhase(playbook, directiveId, 'govern');
501
+ const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
502
+
503
+ // notification_actions — compute ISO deadlines from clock_starts events.
504
+ const notificationActions = (c.notification_actions || []).map(na => {
505
+ const obligation = (g.jurisdiction_obligations || []).find(o =>
506
+ `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
507
+ );
508
+ const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals) : null;
509
+ const deadline = obligation && clockStart
510
+ ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
511
+ : 'pending_clock_start_event';
512
+ return {
513
+ ...na,
514
+ deadline,
515
+ clock_start_event: obligation?.clock_starts,
516
+ clock_started_at: clockStart?.toISOString() || null,
517
+ draft_notification: interpolate(na.draft_notification, { ...agentSignals, ...analyzeFindingShape(analyzeResult) })
518
+ };
519
+ });
520
+
521
+ // exception_generation — evaluate trigger.
522
+ let exception = null;
523
+ if (c.exception_generation) {
524
+ const triggered = evalCondition(c.exception_generation.trigger_condition, agentSignals, playbook);
525
+ if (triggered) {
526
+ const t = c.exception_generation.exception_template;
527
+ exception = {
528
+ scope: interpolate(t.scope, { ...agentSignals, ...analyzeFindingShape(analyzeResult) }),
529
+ duration: t.duration,
530
+ compensating_controls: t.compensating_controls,
531
+ risk_acceptance_owner: t.risk_acceptance_owner,
532
+ auditor_ready_language: interpolate(t.auditor_ready_language, {
533
+ ...agentSignals,
534
+ ...analyzeFindingShape(analyzeResult),
535
+ framework_id: playbook.domain.frameworks_in_scope[0] || 'unspecified',
536
+ control_id: analyzeResult.framework_gap_mapping?.[0]?.claimed_control || 'unspecified',
537
+ ciso_name: agentSignals.ciso_name || '<CISO NAME>',
538
+ acceptance_date: new Date().toISOString().slice(0, 10),
539
+ duration_expiry: agentSignals.duration_expiry || 'until vendor patch'
540
+ })
541
+ };
542
+ }
543
+ }
544
+
545
+ // evidence_package
546
+ const evidencePackage = c.evidence_package ? {
547
+ bundle_format: c.evidence_package.bundle_format || 'csaf-2.0',
548
+ contents: c.evidence_package.contents || [],
549
+ destination: c.evidence_package.destination || 'local_only',
550
+ signed: c.evidence_package.signed !== false,
551
+ bundle_body: buildEvidenceBundle(c.evidence_package.bundle_format || 'csaf-2.0', playbook, analyzeResult, validateResult, agentSignals)
552
+ } : null;
553
+
554
+ if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
555
+ const body = JSON.stringify(evidencePackage.bundle_body);
556
+ evidencePackage.signature = crypto
557
+ .createHmac('sha256', runOpts.session_key)
558
+ .update(body)
559
+ .digest('hex');
560
+ evidencePackage.signature_algorithm = 'HMAC-SHA256-session-key';
561
+ } else if (evidencePackage && evidencePackage.signed) {
562
+ evidencePackage.signature = null;
563
+ evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node lib/sign.js sign-evidence <bundle.json>` post-emit.';
564
+ }
565
+
566
+ // learning_loop lesson
567
+ const lesson = c.learning_loop?.enabled ? {
568
+ enabled: true,
569
+ attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, analyzeFindingShape(analyzeResult)),
570
+ control_gap: c.learning_loop.lesson_template.control_gap,
571
+ framework_gap: c.learning_loop.lesson_template.framework_gap,
572
+ new_control_requirement: c.learning_loop.lesson_template.new_control_requirement,
573
+ feeds_back_to_skills: c.learning_loop.feeds_back_to_skills || [],
574
+ proposed_for_zeroday_lessons_id: `lesson-${playbook._meta.id}-${sessionId}`
575
+ } : { enabled: false };
576
+
577
+ // regression_schedule
578
+ const regressionSchedule = c.regression_schedule ? {
579
+ next_run: validateResult.regression_next_run,
580
+ trigger: c.regression_schedule.trigger,
581
+ notify_on_skip: c.regression_schedule.notify_on_skip !== false
582
+ } : null;
583
+
584
+ // feeds_into chaining — full analyze result is exposed so conditions can
585
+ // reference `analyze.compliance_theater_check.verdict` etc.
586
+ const feedsCtx = {
587
+ rwep: analyzeResult.rwep?.adjusted,
588
+ theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 0 : 100,
589
+ analyze: analyzeResult,
590
+ validate: validateResult,
591
+ finding: analyzeFindingShape(analyzeResult),
592
+ ...agentSignals
593
+ };
594
+ const feeds = (playbook._meta.feeds_into || [])
595
+ .filter(f => evalCondition(f.condition, feedsCtx, playbook))
596
+ .map(f => f.playbook_id);
597
+
598
+ return {
599
+ phase: 'close',
600
+ playbook_id: playbookId,
601
+ directive_id: directiveId,
602
+ evidence_package: evidencePackage,
603
+ learning_loop: lesson,
604
+ notification_actions: notificationActions,
605
+ exception: exception,
606
+ regression_schedule: regressionSchedule,
607
+ feeds_into: feeds
608
+ };
609
+ }
610
+
611
+ function analyzeFindingShape(a) {
612
+ return {
613
+ matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
614
+ matched_cve_count: (a.matched_cves || []).length,
615
+ kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
616
+ active_exploitation: (a.matched_cves || []).find(c => c.active_exploitation)?.active_exploitation || 'unknown',
617
+ rwep_adjusted: a.rwep?.adjusted ?? 0,
618
+ rwep_base: a.rwep?.base ?? 0,
619
+ blast_radius_score: a.blast_radius_score ?? 0,
620
+ framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
621
+ control_id_first: a.framework_gap_mapping?.[0]?.claimed_control || null
622
+ };
623
+ }
624
+
625
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals) {
626
+ // CSAF-2.0 shape — minimal valid envelope; production GRC submission would
627
+ // need full distribution + product_tree population, deferred to the GRC
628
+ // integration layer.
629
+ if (format === 'csaf-2.0') {
630
+ return {
631
+ document: {
632
+ category: 'csaf_security_advisory',
633
+ csaf_version: '2.0',
634
+ publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
635
+ title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} catalogued CVEs)`,
636
+ tracking: {
637
+ id: `exceptd-${playbook._meta.id}-${Date.now()}`,
638
+ status: 'final',
639
+ version: playbook._meta.version,
640
+ initial_release_date: new Date().toISOString(),
641
+ revision_history: [{ number: '1', date: new Date().toISOString(), summary: 'Initial finding emission' }]
642
+ }
643
+ },
644
+ vulnerabilities: analyze.matched_cves.map(c => ({
645
+ cve: c.cve_id,
646
+ scores: [{ products: [], cvss_v3: { base_score: 0 } }],
647
+ threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
648
+ remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
649
+ })),
650
+ exceptd_extension: {
651
+ rwep: analyze.rwep,
652
+ blast_radius_score: analyze.blast_radius_score,
653
+ compliance_theater: analyze.compliance_theater,
654
+ framework_gap_mapping: analyze.framework_gap_mapping,
655
+ evidence_requirements: validate.evidence_requirements,
656
+ residual_risk_statement: validate.residual_risk_statement
657
+ }
658
+ };
659
+ }
660
+ // Other formats deferred.
661
+ return { format, note: 'Non-CSAF formats deferred to GRC integration layer.', analyze, validate };
662
+ }
663
+
664
+ // --- orchestrate: full run in one call ---
665
+
666
+ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
667
+ const playbook = loadPlaybook(playbookId);
668
+ const pre = preflight(playbook, runOpts);
669
+ if (!pre.ok) {
670
+ return { ok: false, phase: 'preflight', blocked_by: pre.blocked_by, reason: pre.reason, issues: pre.issues };
671
+ }
672
+
673
+ _activeRuns.add(playbookId);
674
+ try {
675
+ const phases = {
676
+ govern: govern(playbookId, directiveId, runOpts),
677
+ direct: direct(playbookId, directiveId),
678
+ look: look(playbookId, directiveId, runOpts),
679
+ detect: detect(playbookId, directiveId, agentSubmission),
680
+ };
681
+ phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {});
682
+ phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {});
683
+ phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, runOpts);
684
+
685
+ const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
686
+ const evidenceHash = crypto.createHash('sha256')
687
+ .update(JSON.stringify({
688
+ playbookId, directiveId,
689
+ cves: phases.analyze.matched_cves.map(c => c.cve_id),
690
+ rwep: phases.analyze.rwep.adjusted,
691
+ classification: phases.detect.classification
692
+ }))
693
+ .digest('hex');
694
+
695
+ return {
696
+ ok: true,
697
+ playbook_id: playbookId,
698
+ directive_id: directiveId,
699
+ session_id: sessionId,
700
+ evidence_hash: evidenceHash,
701
+ preflight_issues: pre.issues,
702
+ phases
703
+ };
704
+ } finally {
705
+ _activeRuns.delete(playbookId);
706
+ }
707
+ }
708
+
709
+ // --- helpers ---
710
+
711
+ function evalCondition(expr, ctx, playbook) {
712
+ if (!expr) return false;
713
+ expr = expr.trim();
714
+ expr = stripOuterParens(expr);
715
+ if (expr === 'always') return true;
716
+ if (expr === 'true') return true;
717
+ if (expr === 'false') return false;
718
+
719
+ // Honor operator precedence: OR is lower precedence than AND, so split on OR
720
+ // first. splitAtTopLevel walks the expression depth-aware so parens correctly
721
+ // group sub-expressions — i.e. `A OR (B AND C)` parses with B,C as one AND
722
+ // group rather than splitting at the inner AND.
723
+ const orParts = splitAtTopLevel(expr, 'OR');
724
+ if (orParts.length > 1) return orParts.some(s => evalCondition(s, ctx, playbook));
725
+
726
+ const andParts = splitAtTopLevel(expr, 'AND');
727
+ if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
728
+
729
+ // "rwep >= 90"
730
+ let m = expr.match(/^(\w+(?:\.\w+)*)\s*(>=|<=|==|=|<|>|!=)\s*(['"]?)([^'"]+)\3$/);
731
+ if (m) {
732
+ const [, lhs, op, quote, rhsRaw] = m;
733
+ const lv = resolvePath(ctx, lhs);
734
+ let rv = rhsRaw;
735
+ if (quote) {
736
+ // Explicit quoted string literal — keep as-is.
737
+ } else if (rv === 'true') rv = true;
738
+ else if (rv === 'false') rv = false;
739
+ else if (!isNaN(parseFloat(rv)) && /^-?\d+(\.\d+)?$/.test(rv.trim())) rv = parseFloat(rv);
740
+ else if (/^[a-z_][\w.]*$/i.test(rv.trim())) {
741
+ // Unquoted identifier — treat as a context path. Falls through to the
742
+ // raw string if resolution returns undefined (matches the prior behavior
743
+ // for literals like `theater` that aren't quoted).
744
+ const resolved = resolvePath(ctx, rv.trim());
745
+ if (resolved !== undefined && resolved !== null) rv = resolved;
746
+ }
747
+ switch (op) {
748
+ case '==': case '=': return lv == rv;
749
+ case '!=': return lv != rv;
750
+ case '>=': return lv >= rv;
751
+ case '<=': return lv <= rv;
752
+ case '>': return lv > rv;
753
+ case '<': return lv < rv;
754
+ }
755
+ }
756
+
757
+ // "scope.targets includes named_remote"
758
+ m = expr.match(/^(\w+(?:\.\w+)*)\s+includes\s+(\w+)$/);
759
+ if (m) {
760
+ const arr = resolvePath(ctx, m[1]);
761
+ return Array.isArray(arr) && arr.includes(m[2]);
762
+ }
763
+
764
+ // "matched_cve.vector matches /regex/"
765
+ m = expr.match(/^(\w+(?:\.\w+)*)\s+matches\s+\/(.+)\/$/);
766
+ if (m) {
767
+ const val = resolvePath(ctx, m[1]);
768
+ if (typeof val !== 'string') return false;
769
+ return new RegExp(m[2], 'i').test(val);
770
+ }
771
+
772
+ if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
773
+ return false;
774
+ }
775
+
776
+ function resolvePath(obj, dot) {
777
+ return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
778
+ }
779
+
780
+ /**
781
+ * Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
782
+ * surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
783
+ * sub-expression list. Used by evalCondition so `A OR (B AND C)` splits into
784
+ * [`A`, `(B AND C)`] on OR, instead of naively splitting at the inner AND.
785
+ */
786
+ function splitAtTopLevel(expr, sep) {
787
+ const parts = [];
788
+ const needle = ' ' + sep + ' ';
789
+ let depth = 0, buf = '', i = 0;
790
+ while (i < expr.length) {
791
+ const ch = expr[i];
792
+ if (ch === '(') { depth++; buf += ch; i++; continue; }
793
+ if (ch === ')') { depth--; buf += ch; i++; continue; }
794
+ if (depth === 0 && expr.startsWith(needle, i)) {
795
+ parts.push(buf.trim());
796
+ buf = '';
797
+ i += needle.length;
798
+ continue;
799
+ }
800
+ buf += ch;
801
+ i++;
802
+ }
803
+ parts.push(buf.trim());
804
+ return parts;
805
+ }
806
+
807
+ /**
808
+ * Strip a balanced pair of outer parens, if and only if the very first and last
809
+ * characters are matching parens at the same depth boundary. `(A) AND (B)` keeps
810
+ * its parens; `((A AND B))` peels one layer.
811
+ */
812
+ function stripOuterParens(expr) {
813
+ while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
814
+ let depth = 0;
815
+ let outerMatches = true;
816
+ for (let i = 0; i < expr.length - 1; i++) {
817
+ if (expr[i] === '(') depth++;
818
+ else if (expr[i] === ')') depth--;
819
+ if (depth === 0 && i < expr.length - 1) { outerMatches = false; break; }
820
+ }
821
+ if (outerMatches) expr = expr.slice(1, -1).trim();
822
+ else break;
823
+ }
824
+ return expr;
825
+ }
826
+
827
+ function computeClockStart(eventName, agentSignals) {
828
+ // The agent submits clock_started_at_<event> ISO strings as it progresses.
829
+ const key = `clock_started_at_${eventName}`;
830
+ if (agentSignals[key]) return new Date(agentSignals[key]);
831
+ // Fallback: use the standard 'detect_confirmed' default of "now" for the
832
+ // most common case so notification deadlines aren't always pending.
833
+ if (eventName === 'detect_confirmed' && agentSignals.detection_classification === 'detected') {
834
+ return new Date();
835
+ }
836
+ return null;
837
+ }
838
+
839
+ function expressionKey(expr) {
840
+ // For agentSignals precondition lookups — strip operators/values to leave key.
841
+ const m = expr.match(/^(\w+(?:\.\w+)*)/);
842
+ return m ? m[1] : expr;
843
+ }
844
+
845
+ function interpolate(tpl, ctx) {
846
+ if (!tpl || typeof tpl !== 'string') return tpl;
847
+ return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
848
+ const v = ctx[key];
849
+ return v !== undefined && v !== null ? String(v) : `\${${key}}`;
850
+ });
851
+ }
852
+
853
+ // --- pre-run discovery API: list all directives across all playbooks ---
854
+
855
+ function plan(opts = {}) {
856
+ const ids = opts.playbookIds || listPlaybooks();
857
+ return {
858
+ contract: 'seven-phase: govern → direct → look → detect → analyze → validate → close',
859
+ host_ai_owns: ['look', 'detect'],
860
+ exceptd_owns: ['govern', 'direct', 'analyze', 'validate', 'close'],
861
+ generated_at: new Date().toISOString(),
862
+ session_id: opts.session_id || crypto.randomBytes(8).toString('hex'),
863
+ playbooks: ids.map(id => {
864
+ const pb = loadPlaybook(id);
865
+ return {
866
+ id,
867
+ domain: pb.domain,
868
+ scope: pb._meta.scope || null,
869
+ threat_currency_score: pb._meta.threat_currency_score,
870
+ air_gap_mode: !!pb._meta.air_gap_mode,
871
+ directives: pb.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }))
872
+ };
873
+ })
874
+ };
875
+ }
876
+
877
+ module.exports = {
878
+ listPlaybooks,
879
+ loadPlaybook,
880
+ plan,
881
+ preflight,
882
+ govern,
883
+ direct,
884
+ look,
885
+ detect,
886
+ analyze,
887
+ validate,
888
+ close,
889
+ run,
890
+ // internal helpers exposed for tests
891
+ _resolvedPhase: resolvedPhase,
892
+ _deepMerge: deepMerge,
893
+ _evalCondition: evalCondition,
894
+ _interpolate: interpolate,
895
+ _activeRuns: _activeRuns,
896
+ };