@blamejs/exceptd-skills 0.9.4 → 0.10.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.
@@ -0,0 +1,826 @@
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
+ issues.push({ kind: 'precondition_unverified', id: pc.id, check: pc.check, on_fail: pc.on_fail });
126
+ if (pc.on_fail === 'halt') {
127
+ return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} (${pc.check}) not verified by host AI; on_fail=halt.`, issues };
128
+ }
129
+ continue;
130
+ }
131
+ if (submitted === false) {
132
+ if (pc.on_fail === 'halt') {
133
+ return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
134
+ }
135
+ issues.push({ kind: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn', id: pc.id, message: pc.description });
136
+ }
137
+ }
138
+
139
+ // 3. Mutex
140
+ for (const conflictId of meta.mutex || []) {
141
+ if (_activeRuns.has(conflictId)) {
142
+ return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict: playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
143
+ }
144
+ }
145
+
146
+ return { ok: true, issues };
147
+ }
148
+
149
+ // --- phase 1: govern ---
150
+
151
+ /**
152
+ * Load GRC context for the agent. Returns jurisdiction obligations (with
153
+ * window_hours + clock_starts so close() can compute deadlines later), theater
154
+ * fingerprints, framework gap summary, and skills to preload.
155
+ */
156
+ function govern(playbookId, directiveId, runOpts = {}) {
157
+ const playbook = loadPlaybook(playbookId);
158
+ const g = resolvedPhase(playbook, directiveId, 'govern');
159
+ return {
160
+ phase: 'govern',
161
+ playbook_id: playbookId,
162
+ directive_id: directiveId,
163
+ domain: playbook.domain,
164
+ threat_currency_score: playbook._meta.threat_currency_score,
165
+ last_threat_review: playbook._meta.last_threat_review,
166
+ air_gap_mode: !!playbook._meta.air_gap_mode || !!runOpts.airGap,
167
+ jurisdiction_obligations: g.jurisdiction_obligations || [],
168
+ theater_fingerprints: g.theater_fingerprints || [],
169
+ framework_context: g.framework_context || {},
170
+ skill_preload: g.skill_preload || []
171
+ };
172
+ }
173
+
174
+ // --- phase 2: direct ---
175
+
176
+ function direct(playbookId, directiveId) {
177
+ const playbook = loadPlaybook(playbookId);
178
+ const d = resolvedPhase(playbook, directiveId, 'direct');
179
+ return {
180
+ phase: 'direct',
181
+ playbook_id: playbookId,
182
+ directive_id: directiveId,
183
+ threat_context: d.threat_context,
184
+ rwep_threshold: d.rwep_threshold,
185
+ framework_lag_declaration: d.framework_lag_declaration,
186
+ skill_chain: d.skill_chain || [],
187
+ token_budget: d.token_budget || {}
188
+ };
189
+ }
190
+
191
+ // --- phase 3: look (engine emits, agent executes) ---
192
+
193
+ function look(playbookId, directiveId, runOpts = {}) {
194
+ const playbook = loadPlaybook(playbookId);
195
+ const l = resolvedPhase(playbook, directiveId, 'look');
196
+ const airGap = !!playbook._meta.air_gap_mode || !!runOpts.airGap;
197
+ return {
198
+ phase: 'look',
199
+ playbook_id: playbookId,
200
+ directive_id: directiveId,
201
+ air_gap_mode: airGap,
202
+ artifacts: (l.artifacts || []).map(a => ({
203
+ ...a,
204
+ // Surface the air-gap alternative as the primary source when air_gap_mode
205
+ // is active, so the agent doesn't accidentally hit the network.
206
+ source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
207
+ _original_source: a.source
208
+ })),
209
+ collection_scope: l.collection_scope,
210
+ environment_assumptions: l.environment_assumptions || [],
211
+ fallback_if_unavailable: l.fallback_if_unavailable || []
212
+ };
213
+ }
214
+
215
+ // --- phase 4: detect ---
216
+
217
+ /**
218
+ * Evaluate artifacts the agent submitted against the playbook's typed
219
+ * indicators. Returns a per-indicator hit/miss/inconclusive verdict plus a
220
+ * minimum_signal classification (detected | inconclusive | not_detected).
221
+ *
222
+ * The agent submits `artifacts` as { artifact_id: { value, captured: true|false, reason? } }
223
+ * and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
224
+ * record an indicator outcome the agent computed using its own pattern matching.
225
+ */
226
+ function detect(playbookId, directiveId, agentSubmission = {}) {
227
+ const playbook = loadPlaybook(playbookId);
228
+ const det = resolvedPhase(playbook, directiveId, 'detect');
229
+ const artifacts = agentSubmission.artifacts || {};
230
+ const overrides = agentSubmission.signal_overrides || {};
231
+
232
+ const indicatorResults = (det.indicators || []).map(ind => {
233
+ const override = overrides[ind.id];
234
+ let verdict;
235
+ if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
236
+ verdict = override;
237
+ } else {
238
+ // Without an explicit override, treat any captured artifact as evidence
239
+ // the indicator could be evaluated. Mark inconclusive if no related
240
+ // artifact was captured — engine doesn't pattern-match raw artifact
241
+ // content; the host AI is responsible for that.
242
+ const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
243
+ verdict = anyCaptured ? 'inconclusive' : 'inconclusive';
244
+ }
245
+ return {
246
+ id: ind.id, type: ind.type, confidence: ind.confidence,
247
+ deterministic: ind.deterministic, atlas_ref: ind.atlas_ref || null,
248
+ attack_ref: ind.attack_ref || null, verdict
249
+ };
250
+ });
251
+
252
+ // false-positive profile — engine highlights which FP tests the agent
253
+ // should still run against any indicator the agent reported as 'hit'.
254
+ const fpChecksRequired = (det.false_positive_profile || []).filter(fp =>
255
+ indicatorResults.find(r => r.id === fp.indicator_id && r.verdict === 'hit')
256
+ );
257
+
258
+ const hits = indicatorResults.filter(r => r.verdict === 'hit');
259
+ const hasDeterministicHit = hits.some(r => r.deterministic);
260
+ const hasHighConfHit = hits.some(r => r.confidence === 'high' || r.confidence === 'deterministic');
261
+
262
+ let classification;
263
+ if (hasDeterministicHit || hasHighConfHit) {
264
+ classification = 'detected';
265
+ } else if (hits.length === 0 && indicatorResults.every(r => r.verdict === 'miss')) {
266
+ classification = 'not_detected';
267
+ } else {
268
+ classification = 'inconclusive';
269
+ }
270
+
271
+ return {
272
+ phase: 'detect',
273
+ playbook_id: playbookId,
274
+ directive_id: directiveId,
275
+ indicators: indicatorResults,
276
+ false_positive_checks_required: fpChecksRequired,
277
+ classification,
278
+ minimum_signal_basis: det.minimum_signal?.[classification === 'detected' ? 'detected' : classification === 'not_detected' ? 'not_detected' : 'inconclusive']
279
+ };
280
+ }
281
+
282
+ // --- phase 5: analyze ---
283
+
284
+ /**
285
+ * RWEP composition + blast-radius scoring + theater check + framework gap
286
+ * mapping + escalation evaluation. Inputs are the detect result + any
287
+ * agent-submitted signal_values (e.g. blast_radius classification).
288
+ */
289
+ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
290
+ const playbook = loadPlaybook(playbookId);
291
+ const an = resolvedPhase(playbook, directiveId, 'analyze');
292
+ const directive = findDirective(playbook, directiveId);
293
+
294
+ // Match catalogued CVEs from the domain.cve_refs list. The agent submits
295
+ // signal values; engine joins to the catalog for RWEP context.
296
+ const cveRefs = playbook.domain.cve_refs || [];
297
+ const matchedCves = cveRefs
298
+ .map(id => xref.byCve(id))
299
+ .filter(r => r.found);
300
+
301
+ // RWEP composition: start from the catalogue's per-CVE rwep_score (already
302
+ // baked from KEV + PoC + AI-disc + active-exploitation + blast-radius), then
303
+ // adjust by playbook's rwep_inputs based on detect hits + agent signals.
304
+ const baseRwep = matchedCves.length ? Math.max(...matchedCves.map(c => c.rwep_score)) : 0;
305
+ let adjustedRwep = baseRwep;
306
+ const rwepBreakdown = [];
307
+ for (const input of an.rwep_inputs || []) {
308
+ const indicator = detectResult.indicators?.find(i => i.id === input.signal_id);
309
+ const fired = indicator?.verdict === 'hit' || agentSignals[input.signal_id] === true;
310
+ if (fired) {
311
+ adjustedRwep += input.weight;
312
+ rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: input.weight, fired: true });
313
+ } else {
314
+ rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false });
315
+ }
316
+ }
317
+ adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
318
+
319
+ // blast_radius
320
+ const blastRubric = an.blast_radius_model?.scoring_rubric || [];
321
+ const blastRadiusScore = agentSignals.blast_radius_score || (blastRubric[0]?.blast_radius_score ?? null);
322
+
323
+ // compliance_theater_check — engine surfaces the test; agent runs it; we
324
+ // accept the verdict in agentSignals.theater_verdict.
325
+ const theaterVerdict = agentSignals.theater_verdict || (an.compliance_theater_check ? 'pending_agent_run' : null);
326
+
327
+ // framework_gap_mapping — engine emits the mapping verbatim; analyze does
328
+ // not compute new gaps here, just attaches the playbook-declared ones.
329
+ const frameworkGaps = an.framework_gap_mapping || [];
330
+
331
+ // escalation criteria
332
+ const escalations = [];
333
+ for (const ec of an.escalation_criteria || []) {
334
+ if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals }, playbook)) {
335
+ escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
336
+ }
337
+ }
338
+
339
+ return {
340
+ phase: 'analyze',
341
+ playbook_id: playbookId,
342
+ directive_id: directiveId,
343
+ matched_cves: matchedCves.map(c => ({
344
+ cve_id: c.cve_id, rwep: c.rwep_score, cisa_kev: c.cisa_kev,
345
+ active_exploitation: c.active_exploitation, ai_discovered: c.ai_discovered
346
+ })),
347
+ rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null },
348
+ blast_radius_score: blastRadiusScore,
349
+ blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
350
+ compliance_theater_check: {
351
+ claim: an.compliance_theater_check?.claim,
352
+ audit_evidence: an.compliance_theater_check?.audit_evidence,
353
+ reality_test: an.compliance_theater_check?.reality_test,
354
+ verdict: theaterVerdict,
355
+ verdict_text: theaterVerdict === 'theater' ? an.compliance_theater_check?.theater_verdict_if_gap : null
356
+ },
357
+ framework_gap_mapping: frameworkGaps,
358
+ escalations
359
+ };
360
+ }
361
+
362
+ // --- phase 6: validate ---
363
+
364
+ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
365
+ const playbook = loadPlaybook(playbookId);
366
+ const v = resolvedPhase(playbook, directiveId, 'validate');
367
+
368
+ // Pick the highest-priority remediation_path whose preconditions are all
369
+ // either satisfied by agentSignals or marked unverified=allow.
370
+ const paths = (v.remediation_paths || []).slice().sort((a, b) => a.priority - b.priority);
371
+ let selected = null;
372
+ const considered = [];
373
+ for (const p of paths) {
374
+ const pcResult = (p.preconditions || []).map(expr => ({
375
+ expr,
376
+ satisfied: evalCondition(expr, agentSignals, playbook),
377
+ submitted: agentSignals[expressionKey(expr)] !== undefined
378
+ }));
379
+ const allSatisfied = pcResult.every(x => x.satisfied);
380
+ considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, preconditions: pcResult });
381
+ if (allSatisfied && !selected) selected = p;
382
+ }
383
+ // Always at least propose the highest-priority path even if preconditions
384
+ // weren't verified — the agent can surface that to the operator.
385
+ if (!selected && paths.length) selected = paths[0];
386
+
387
+ // Compute regression schedule next_run (engine sets a single soonest run).
388
+ const triggers = v.regression_trigger || [];
389
+ const nextRun = computeRegressionNextRun(triggers);
390
+
391
+ return {
392
+ phase: 'validate',
393
+ playbook_id: playbookId,
394
+ directive_id: directiveId,
395
+ selected_remediation: selected,
396
+ remediation_options_considered: considered,
397
+ validation_tests: v.validation_tests || [],
398
+ residual_risk_statement: v.residual_risk_statement || null,
399
+ evidence_requirements: v.evidence_requirements || [],
400
+ regression_trigger: triggers,
401
+ regression_next_run: nextRun
402
+ };
403
+ }
404
+
405
+ function computeRegressionNextRun(triggers) {
406
+ const now = new Date();
407
+ let soonest = null;
408
+ for (const t of triggers) {
409
+ const m = (t.interval || '').match(/^(\d+)d$/);
410
+ if (m) {
411
+ const d = new Date(now.getTime() + parseInt(m[1], 10) * 24 * 3600 * 1000);
412
+ if (!soonest || d < soonest) soonest = d;
413
+ }
414
+ }
415
+ return soonest ? soonest.toISOString() : null;
416
+ }
417
+
418
+ // --- phase 7: close ---
419
+
420
+ /**
421
+ * Assemble the closure artifacts:
422
+ * - evidence_package (CSAF-2.0 shaped if requested; signed if signing key present)
423
+ * - learning_loop lesson template populated with current finding context
424
+ * - notification_actions with computed ISO 8601 deadlines from clock_starts + window_hours
425
+ * - exception_generation auditor-ready language if trigger fires
426
+ * - regression_schedule.next_run from validate.regression_next_run
427
+ * - feeds_into chaining suggestions
428
+ */
429
+ function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
430
+ const playbook = loadPlaybook(playbookId);
431
+ const c = resolvedPhase(playbook, directiveId, 'close');
432
+ const g = resolvedPhase(playbook, directiveId, 'govern');
433
+ const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
434
+
435
+ // notification_actions — compute ISO deadlines from clock_starts events.
436
+ const notificationActions = (c.notification_actions || []).map(na => {
437
+ const obligation = (g.jurisdiction_obligations || []).find(o =>
438
+ `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
439
+ );
440
+ const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals) : null;
441
+ const deadline = obligation && clockStart
442
+ ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
443
+ : 'pending_clock_start_event';
444
+ return {
445
+ ...na,
446
+ deadline,
447
+ clock_start_event: obligation?.clock_starts,
448
+ clock_started_at: clockStart?.toISOString() || null,
449
+ draft_notification: interpolate(na.draft_notification, { ...agentSignals, ...analyzeFindingShape(analyzeResult) })
450
+ };
451
+ });
452
+
453
+ // exception_generation — evaluate trigger.
454
+ let exception = null;
455
+ if (c.exception_generation) {
456
+ const triggered = evalCondition(c.exception_generation.trigger_condition, agentSignals, playbook);
457
+ if (triggered) {
458
+ const t = c.exception_generation.exception_template;
459
+ exception = {
460
+ scope: interpolate(t.scope, { ...agentSignals, ...analyzeFindingShape(analyzeResult) }),
461
+ duration: t.duration,
462
+ compensating_controls: t.compensating_controls,
463
+ risk_acceptance_owner: t.risk_acceptance_owner,
464
+ auditor_ready_language: interpolate(t.auditor_ready_language, {
465
+ ...agentSignals,
466
+ ...analyzeFindingShape(analyzeResult),
467
+ framework_id: playbook.domain.frameworks_in_scope[0] || 'unspecified',
468
+ control_id: analyzeResult.framework_gap_mapping?.[0]?.claimed_control || 'unspecified',
469
+ ciso_name: agentSignals.ciso_name || '<CISO NAME>',
470
+ acceptance_date: new Date().toISOString().slice(0, 10),
471
+ duration_expiry: agentSignals.duration_expiry || 'until vendor patch'
472
+ })
473
+ };
474
+ }
475
+ }
476
+
477
+ // evidence_package
478
+ const evidencePackage = c.evidence_package ? {
479
+ bundle_format: c.evidence_package.bundle_format || 'csaf-2.0',
480
+ contents: c.evidence_package.contents || [],
481
+ destination: c.evidence_package.destination || 'local_only',
482
+ signed: c.evidence_package.signed !== false,
483
+ bundle_body: buildEvidenceBundle(c.evidence_package.bundle_format || 'csaf-2.0', playbook, analyzeResult, validateResult, agentSignals)
484
+ } : null;
485
+
486
+ if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
487
+ const body = JSON.stringify(evidencePackage.bundle_body);
488
+ evidencePackage.signature = crypto
489
+ .createHmac('sha256', runOpts.session_key)
490
+ .update(body)
491
+ .digest('hex');
492
+ evidencePackage.signature_algorithm = 'HMAC-SHA256-session-key';
493
+ } else if (evidencePackage && evidencePackage.signed) {
494
+ evidencePackage.signature = null;
495
+ evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node lib/sign.js sign-evidence <bundle.json>` post-emit.';
496
+ }
497
+
498
+ // learning_loop lesson
499
+ const lesson = c.learning_loop?.enabled ? {
500
+ enabled: true,
501
+ attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, analyzeFindingShape(analyzeResult)),
502
+ control_gap: c.learning_loop.lesson_template.control_gap,
503
+ framework_gap: c.learning_loop.lesson_template.framework_gap,
504
+ new_control_requirement: c.learning_loop.lesson_template.new_control_requirement,
505
+ feeds_back_to_skills: c.learning_loop.feeds_back_to_skills || [],
506
+ proposed_for_zeroday_lessons_id: `lesson-${playbook._meta.id}-${sessionId}`
507
+ } : { enabled: false };
508
+
509
+ // regression_schedule
510
+ const regressionSchedule = c.regression_schedule ? {
511
+ next_run: validateResult.regression_next_run,
512
+ trigger: c.regression_schedule.trigger,
513
+ notify_on_skip: c.regression_schedule.notify_on_skip !== false
514
+ } : null;
515
+
516
+ // feeds_into chaining — full analyze result is exposed so conditions can
517
+ // reference `analyze.compliance_theater_check.verdict` etc.
518
+ const feedsCtx = {
519
+ rwep: analyzeResult.rwep?.adjusted,
520
+ theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 0 : 100,
521
+ analyze: analyzeResult,
522
+ validate: validateResult,
523
+ finding: analyzeFindingShape(analyzeResult),
524
+ ...agentSignals
525
+ };
526
+ const feeds = (playbook._meta.feeds_into || [])
527
+ .filter(f => evalCondition(f.condition, feedsCtx, playbook))
528
+ .map(f => f.playbook_id);
529
+
530
+ return {
531
+ phase: 'close',
532
+ playbook_id: playbookId,
533
+ directive_id: directiveId,
534
+ evidence_package: evidencePackage,
535
+ learning_loop: lesson,
536
+ notification_actions: notificationActions,
537
+ exception: exception,
538
+ regression_schedule: regressionSchedule,
539
+ feeds_into: feeds
540
+ };
541
+ }
542
+
543
+ function analyzeFindingShape(a) {
544
+ return {
545
+ matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
546
+ matched_cve_count: (a.matched_cves || []).length,
547
+ kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
548
+ active_exploitation: (a.matched_cves || []).find(c => c.active_exploitation)?.active_exploitation || 'unknown',
549
+ rwep_adjusted: a.rwep?.adjusted ?? 0,
550
+ rwep_base: a.rwep?.base ?? 0,
551
+ blast_radius_score: a.blast_radius_score ?? 0,
552
+ framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
553
+ control_id_first: a.framework_gap_mapping?.[0]?.claimed_control || null
554
+ };
555
+ }
556
+
557
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals) {
558
+ // CSAF-2.0 shape — minimal valid envelope; production GRC submission would
559
+ // need full distribution + product_tree population, deferred to the GRC
560
+ // integration layer.
561
+ if (format === 'csaf-2.0') {
562
+ return {
563
+ document: {
564
+ category: 'csaf_security_advisory',
565
+ csaf_version: '2.0',
566
+ publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
567
+ title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} catalogued CVEs)`,
568
+ tracking: {
569
+ id: `exceptd-${playbook._meta.id}-${Date.now()}`,
570
+ status: 'final',
571
+ version: playbook._meta.version,
572
+ initial_release_date: new Date().toISOString(),
573
+ revision_history: [{ number: '1', date: new Date().toISOString(), summary: 'Initial finding emission' }]
574
+ }
575
+ },
576
+ vulnerabilities: analyze.matched_cves.map(c => ({
577
+ cve: c.cve_id,
578
+ scores: [{ products: [], cvss_v3: { base_score: 0 } }],
579
+ threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
580
+ remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
581
+ })),
582
+ exceptd_extension: {
583
+ rwep: analyze.rwep,
584
+ blast_radius_score: analyze.blast_radius_score,
585
+ compliance_theater: analyze.compliance_theater,
586
+ framework_gap_mapping: analyze.framework_gap_mapping,
587
+ evidence_requirements: validate.evidence_requirements,
588
+ residual_risk_statement: validate.residual_risk_statement
589
+ }
590
+ };
591
+ }
592
+ // Other formats deferred.
593
+ return { format, note: 'Non-CSAF formats deferred to GRC integration layer.', analyze, validate };
594
+ }
595
+
596
+ // --- orchestrate: full run in one call ---
597
+
598
+ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
599
+ const playbook = loadPlaybook(playbookId);
600
+ const pre = preflight(playbook, runOpts);
601
+ if (!pre.ok) {
602
+ return { ok: false, phase: 'preflight', blocked_by: pre.blocked_by, reason: pre.reason, issues: pre.issues };
603
+ }
604
+
605
+ _activeRuns.add(playbookId);
606
+ try {
607
+ const phases = {
608
+ govern: govern(playbookId, directiveId, runOpts),
609
+ direct: direct(playbookId, directiveId),
610
+ look: look(playbookId, directiveId, runOpts),
611
+ detect: detect(playbookId, directiveId, agentSubmission),
612
+ };
613
+ phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {});
614
+ phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {});
615
+ phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, runOpts);
616
+
617
+ const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
618
+ const evidenceHash = crypto.createHash('sha256')
619
+ .update(JSON.stringify({
620
+ playbookId, directiveId,
621
+ cves: phases.analyze.matched_cves.map(c => c.cve_id),
622
+ rwep: phases.analyze.rwep.adjusted,
623
+ classification: phases.detect.classification
624
+ }))
625
+ .digest('hex');
626
+
627
+ return {
628
+ ok: true,
629
+ playbook_id: playbookId,
630
+ directive_id: directiveId,
631
+ session_id: sessionId,
632
+ evidence_hash: evidenceHash,
633
+ preflight_issues: pre.issues,
634
+ phases
635
+ };
636
+ } finally {
637
+ _activeRuns.delete(playbookId);
638
+ }
639
+ }
640
+
641
+ // --- helpers ---
642
+
643
+ function evalCondition(expr, ctx, playbook) {
644
+ if (!expr) return false;
645
+ expr = expr.trim();
646
+ expr = stripOuterParens(expr);
647
+ if (expr === 'always') return true;
648
+ if (expr === 'true') return true;
649
+ if (expr === 'false') return false;
650
+
651
+ // Honor operator precedence: OR is lower precedence than AND, so split on OR
652
+ // first. splitAtTopLevel walks the expression depth-aware so parens correctly
653
+ // group sub-expressions — i.e. `A OR (B AND C)` parses with B,C as one AND
654
+ // group rather than splitting at the inner AND.
655
+ const orParts = splitAtTopLevel(expr, 'OR');
656
+ if (orParts.length > 1) return orParts.some(s => evalCondition(s, ctx, playbook));
657
+
658
+ const andParts = splitAtTopLevel(expr, 'AND');
659
+ if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
660
+
661
+ // "rwep >= 90"
662
+ let m = expr.match(/^(\w+(?:\.\w+)*)\s*(>=|<=|==|=|<|>|!=)\s*(['"]?)([^'"]+)\3$/);
663
+ if (m) {
664
+ const [, lhs, op, quote, rhsRaw] = m;
665
+ const lv = resolvePath(ctx, lhs);
666
+ let rv = rhsRaw;
667
+ if (quote) {
668
+ // Explicit quoted string literal — keep as-is.
669
+ } else if (rv === 'true') rv = true;
670
+ else if (rv === 'false') rv = false;
671
+ else if (!isNaN(parseFloat(rv)) && /^-?\d+(\.\d+)?$/.test(rv.trim())) rv = parseFloat(rv);
672
+ else if (/^[a-z_][\w.]*$/i.test(rv.trim())) {
673
+ // Unquoted identifier — treat as a context path. Falls through to the
674
+ // raw string if resolution returns undefined (matches the prior behavior
675
+ // for literals like `theater` that aren't quoted).
676
+ const resolved = resolvePath(ctx, rv.trim());
677
+ if (resolved !== undefined && resolved !== null) rv = resolved;
678
+ }
679
+ switch (op) {
680
+ case '==': case '=': return lv == rv;
681
+ case '!=': return lv != rv;
682
+ case '>=': return lv >= rv;
683
+ case '<=': return lv <= rv;
684
+ case '>': return lv > rv;
685
+ case '<': return lv < rv;
686
+ }
687
+ }
688
+
689
+ // "scope.targets includes named_remote"
690
+ m = expr.match(/^(\w+(?:\.\w+)*)\s+includes\s+(\w+)$/);
691
+ if (m) {
692
+ const arr = resolvePath(ctx, m[1]);
693
+ return Array.isArray(arr) && arr.includes(m[2]);
694
+ }
695
+
696
+ // "matched_cve.vector matches /regex/"
697
+ m = expr.match(/^(\w+(?:\.\w+)*)\s+matches\s+\/(.+)\/$/);
698
+ if (m) {
699
+ const val = resolvePath(ctx, m[1]);
700
+ if (typeof val !== 'string') return false;
701
+ return new RegExp(m[2], 'i').test(val);
702
+ }
703
+
704
+ if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
705
+ return false;
706
+ }
707
+
708
+ function resolvePath(obj, dot) {
709
+ return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
710
+ }
711
+
712
+ /**
713
+ * Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
714
+ * surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
715
+ * sub-expression list. Used by evalCondition so `A OR (B AND C)` splits into
716
+ * [`A`, `(B AND C)`] on OR, instead of naively splitting at the inner AND.
717
+ */
718
+ function splitAtTopLevel(expr, sep) {
719
+ const parts = [];
720
+ const needle = ' ' + sep + ' ';
721
+ let depth = 0, buf = '', i = 0;
722
+ while (i < expr.length) {
723
+ const ch = expr[i];
724
+ if (ch === '(') { depth++; buf += ch; i++; continue; }
725
+ if (ch === ')') { depth--; buf += ch; i++; continue; }
726
+ if (depth === 0 && expr.startsWith(needle, i)) {
727
+ parts.push(buf.trim());
728
+ buf = '';
729
+ i += needle.length;
730
+ continue;
731
+ }
732
+ buf += ch;
733
+ i++;
734
+ }
735
+ parts.push(buf.trim());
736
+ return parts;
737
+ }
738
+
739
+ /**
740
+ * Strip a balanced pair of outer parens, if and only if the very first and last
741
+ * characters are matching parens at the same depth boundary. `(A) AND (B)` keeps
742
+ * its parens; `((A AND B))` peels one layer.
743
+ */
744
+ function stripOuterParens(expr) {
745
+ while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
746
+ let depth = 0;
747
+ let outerMatches = true;
748
+ for (let i = 0; i < expr.length - 1; i++) {
749
+ if (expr[i] === '(') depth++;
750
+ else if (expr[i] === ')') depth--;
751
+ if (depth === 0 && i < expr.length - 1) { outerMatches = false; break; }
752
+ }
753
+ if (outerMatches) expr = expr.slice(1, -1).trim();
754
+ else break;
755
+ }
756
+ return expr;
757
+ }
758
+
759
+ function computeClockStart(eventName, agentSignals) {
760
+ // The agent submits clock_started_at_<event> ISO strings as it progresses.
761
+ const key = `clock_started_at_${eventName}`;
762
+ if (agentSignals[key]) return new Date(agentSignals[key]);
763
+ // Fallback: use the standard 'detect_confirmed' default of "now" for the
764
+ // most common case so notification deadlines aren't always pending.
765
+ if (eventName === 'detect_confirmed' && agentSignals.detection_classification === 'detected') {
766
+ return new Date();
767
+ }
768
+ return null;
769
+ }
770
+
771
+ function expressionKey(expr) {
772
+ // For agentSignals precondition lookups — strip operators/values to leave key.
773
+ const m = expr.match(/^(\w+(?:\.\w+)*)/);
774
+ return m ? m[1] : expr;
775
+ }
776
+
777
+ function interpolate(tpl, ctx) {
778
+ if (!tpl || typeof tpl !== 'string') return tpl;
779
+ return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
780
+ const v = ctx[key];
781
+ return v !== undefined && v !== null ? String(v) : `\${${key}}`;
782
+ });
783
+ }
784
+
785
+ // --- pre-run discovery API: list all directives across all playbooks ---
786
+
787
+ function plan(opts = {}) {
788
+ const ids = opts.playbookIds || listPlaybooks();
789
+ return {
790
+ contract: 'seven-phase: govern → direct → look → detect → analyze → validate → close',
791
+ host_ai_owns: ['look', 'detect'],
792
+ exceptd_owns: ['govern', 'direct', 'analyze', 'validate', 'close'],
793
+ generated_at: new Date().toISOString(),
794
+ session_id: opts.session_id || crypto.randomBytes(8).toString('hex'),
795
+ playbooks: ids.map(id => {
796
+ const pb = loadPlaybook(id);
797
+ return {
798
+ id,
799
+ domain: pb.domain,
800
+ threat_currency_score: pb._meta.threat_currency_score,
801
+ directives: pb.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }))
802
+ };
803
+ })
804
+ };
805
+ }
806
+
807
+ module.exports = {
808
+ listPlaybooks,
809
+ loadPlaybook,
810
+ plan,
811
+ preflight,
812
+ govern,
813
+ direct,
814
+ look,
815
+ detect,
816
+ analyze,
817
+ validate,
818
+ close,
819
+ run,
820
+ // internal helpers exposed for tests
821
+ _resolvedPhase: resolvedPhase,
822
+ _deepMerge: deepMerge,
823
+ _evalCondition: evalCondition,
824
+ _interpolate: interpolate,
825
+ _activeRuns: _activeRuns,
826
+ };