@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -0,0 +1,257 @@
1
+ // The single emit function for the one verification record (X-201).
2
+ //
3
+ // Every surface that verifies something (fix verification, the closed fix loop,
4
+ // execution proof, the verifier verdicts, autopilot, hunt, the trusted oracle
5
+ // adapters) calls `emitVerification(surface, source, ctx)` and gets back a
6
+ // version-1 record from `assurance/verification-record.js`. The surface keeps
7
+ // producing its own native fields; the record is ADDITIVE (callers attach it
8
+ // under `verificationRecord`) so no existing consumer changes.
9
+ //
10
+ // The rule that governs every mapping here is the one CORE-002 states: evidence
11
+ // can be lost in translation but never invented. Each surface's native result
12
+ // lands on the strongest outcome its OWN evidence supports, and none of the
13
+ // legacy surfaces ran under the trusted boundary, so none of them can reach
14
+ // `confirmed` or `refuted` through this function:
15
+ // - a PoC that ran under the older sandbox path is `inconclusive` (the older
16
+ // path is not a trusted runner; re-verify under an oracle adapter to confirm);
17
+ // - a PoC that ran and did not fire is `inconclusive`, never `refuted`;
18
+ // - a model verdict is recorded as `inference` evidence and nothing more;
19
+ // - a surface that executed nothing (static legs only) is `not-run`;
20
+ // - a family or class with no oracle is `unsupported`, a harness failure `error`.
21
+ // `confirmed`/`refuted` records come only from `oracle` emissions, whose caller
22
+ // (`oracles/oracle.js`) supplies trusted-runner evidence produced by the
23
+ // verifier domain. This function validates before it returns and never throws.
24
+ import { spawnSync } from 'node:child_process';
25
+ import { buildVerificationRecord, validateVerificationRecord } from '../assurance/verification-record.js';
26
+ import { legacyVerificationOutcome, toLegacyVerificationView } from '../assurance/migrations.js';
27
+ import { digestOf, hypothesisIdFromFinding } from '../assurance/identity.js';
28
+ import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
29
+ import { judgeNonTaintHypothesis } from '../oracles/scenario-classes.js';
30
+
31
+ export const SURFACES = Object.freeze([
32
+ 'fix-verify', 'fix-verify-loop', 'execution-proof', 'verifier', 'autopilot', 'hunt', 'oracle',
33
+ ]);
34
+
35
+ const POC_ORACLE = Object.freeze({ id: 'poc-marker-replay', kind: 'runtime-replay', version: '1' });
36
+ const TAINT_ORACLE = Object.freeze({ id: 'taint-engine-confirmation', kind: 'static-proof', version: '1' });
37
+ const SANITIZER_ORACLE = Object.freeze({ id: 'sanitizer-absence-window', kind: 'static-proof', version: '1' });
38
+
39
+ /** HEAD of `root`, or null when it is not a git work tree. Bounded; never throws. */
40
+ export function headCommit(root) {
41
+ if (!root || typeof root !== 'string') return null;
42
+ try {
43
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 2000 });
44
+ const out = String(r.stdout || '').trim();
45
+ return r.status === 0 && isCommit(out) ? out : null;
46
+ } catch { return null; }
47
+ }
48
+
49
+ function detectorOriginOf(finding, ctx) {
50
+ if (isPlainObject(ctx.detectorOrigin)) return ctx.detectorOrigin;
51
+ const f = finding || {};
52
+ const origin = { detector: String(f.ruleId || f.vuln || f.id || f.parser || 'unknown') };
53
+ if (typeof f.family === 'string' && f.family) origin.family = f.family;
54
+ if (typeof f.parser === 'string' && f.parser) origin.parser = f.parser;
55
+ return origin;
56
+ }
57
+
58
+ const observation = (id, source, value) => ({ id, kind: 'observation', producer: 'tool', digest: digestOf(value ?? null), source });
59
+ const inference = (id, source, value) => ({ id, kind: 'inference', producer: 'model', digest: digestOf(value ?? null), source });
60
+
61
+ function scopeOf(description, backend) {
62
+ const scope = { description, platform: process.platform };
63
+ if (backend && typeof backend === 'string') scope.backend = backend;
64
+ return scope;
65
+ }
66
+
67
+ // ------------------------------------------------------------ per-surface mappers
68
+
69
+ function mapFixVerify(r, ctx) {
70
+ const files = ctx.files && typeof ctx.files === 'object' ? ctx.files : null;
71
+ const repair = files ? { status: 'proposed', patchDigest: digestOf(files) } : { status: 'none' };
72
+ const legs = {
73
+ rescan: { ok: r?.rescan?.ok ?? null, reason: r?.rescan?.reason ?? null },
74
+ lint: { ok: r?.lint?.ok ?? null, runner: r?.lint?.runner ?? null, skipped: !!r?.lint?.skipped },
75
+ tests: { passed: r?.tests?.passed ?? null, skipped: !!r?.tests?.skipped },
76
+ poc: { status: r?.poc?.status ?? 'not-requested', tier: r?.poc?.tier ?? null },
77
+ };
78
+ const evidence = [observation('legs', 'fix-verify legs', legs)];
79
+ const legText = `re-scan ${legs.rescan.ok ? 'passed' : 'failed'}, lint ${legs.lint.skipped ? 'skipped' : legs.lint.ok ? 'passed' : 'failed'}, tests ${legs.tests.skipped ? 'skipped' : legs.tests.passed ? 'passed' : 'failed'}`;
80
+ const base = { repair, evidence, scope: scopeOf('candidate patch re-verification (hypothesis: the vulnerability is still present on the patched tree)', r?.poc?.backend) };
81
+ if (!files || legs.rescan.reason === 'no-files-provided') {
82
+ return { ...base, outcome: 'not-run', reason: 'no candidate patch files were supplied, so nothing was verified', attempt: 0, oracle: null, preconditions: { valid: false } };
83
+ }
84
+ if (legs.rescan.reason === 'rescan-failed') {
85
+ return { ...base, outcome: 'error', reason: 'the verification re-scan failed to run', attempt: 1, oracle: null, preconditions: { valid: false } };
86
+ }
87
+ const p = legs.poc.status;
88
+ if (p === 'not-requested') {
89
+ return {
90
+ ...base, outcome: 'not-run', attempt: 0, oracle: null, preconditions: { valid: false },
91
+ reason: `no exploit oracle was executed against the patch; only detector-level and project-test legs ran (${legText})`,
92
+ };
93
+ }
94
+ // The leg's own free text (it can echo scanned source) is deliberately not copied into the record's reason.
95
+ if (p === 'inconclusive') {
96
+ return { ...base, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: false },
97
+ reason: `the proof-of-concept leg did not complete, so nothing was established about the patch (${legText})` };
98
+ }
99
+ const mapped = legacyVerificationOutcome({ proofEvidence: { tier: p === 'still-exploitable' ? 'execution-proven' : 'proof-failed', ran: true } });
100
+ return {
101
+ ...base, outcome: mapped.outcome, attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true },
102
+ // The leg's native status word ('fixed') is not echoed: it would read as a generic fixed label on a result that decided nothing.
103
+ reason: `proof-of-concept leg ${p === 'still-exploitable' ? 'reproduced the effect' : 'did not reproduce the effect'}: ${mapped.reason} (${legText})`,
104
+ };
105
+ }
106
+
107
+ function mapFixLoop(r, ctx) {
108
+ const files = ctx.files && typeof ctx.files === 'object' ? ctx.files : null;
109
+ const repair = files ? { status: 'proposed', patchDigest: digestOf(files) } : { status: 'none' };
110
+ const legs = r?.legs || {};
111
+ const evidence = [observation('legs', 'fix-verify-loop legs', {
112
+ scan: legs.scan?.ok ?? null, lint: legs.lint?.ok ?? null, tests: { ok: legs.tests?.ok ?? null, skipped: !!legs.tests?.skipped }, verdict: r?.verdict ?? null,
113
+ })];
114
+ const base = { repair, evidence, scope: scopeOf('closed-loop fix verification (scan, lint, project tests)', null), oracle: null, preconditions: { valid: false } };
115
+ if (!files) return { ...base, outcome: 'not-run', attempt: 0, reason: 'no candidate patch files were supplied, so nothing was verified' };
116
+ if (legs.scan?.detail?.reason === 'rescan-failed') return { ...base, outcome: 'error', attempt: 1, reason: 'the verification re-scan failed to run' };
117
+ return {
118
+ ...base, outcome: 'not-run', attempt: 0,
119
+ reason: `loop verdict '${r?.verdict ?? 'unknown'}': the loop runs the detector, linter and project tests; no exploit oracle ran against the patch`,
120
+ };
121
+ }
122
+
123
+ function mapExecutionProof(finding) {
124
+ const pe = isPlainObject(finding?.proofEvidence) ? finding.proofEvidence : null;
125
+ if (!pe) return { outcome: 'not-run', attempt: 0, reason: 'no execution-proof evidence is attached to this finding', oracle: null, evidence: [], preconditions: { valid: false }, scope: scopeOf('execution proof (no evidence attached)', null) };
126
+ const m = legacyVerificationOutcome({ proofEvidence: pe });
127
+ const ran = pe.ran === true;
128
+ return {
129
+ outcome: m.outcome, reason: m.reason, attempt: ['not-run', 'unsupported'].includes(m.outcome) ? 0 : 1,
130
+ oracle: ran ? POC_ORACLE : null,
131
+ evidence: ran ? [observation('proof-evidence', 'execution-proof sandbox run', { tier: pe.tier, backend: pe.backend ?? null, observed: pe.observed ?? null, exitCode: pe.exitCode ?? null })] : [],
132
+ preconditions: { valid: ran },
133
+ scope: scopeOf(`proof-of-concept execution (${pe.proofKind || 'in-process marker proof'})`, pe.backend),
134
+ };
135
+ }
136
+
137
+ // verifier.js verdicts. `cannot-verify` carries its cause in the reason string; the cause decides the state.
138
+ function mapVerifierVerdict(finding) {
139
+ const verdict = finding?.verifier_verdict;
140
+ const reason = String(finding?.verifier_reason || '');
141
+ const scope = scopeOf(`verifier verdict '${verdict}'`, finding?.verifier_runner);
142
+ const none = { oracle: null, evidence: [], preconditions: { valid: false }, scope };
143
+ switch (verdict) {
144
+ case 'verified-exploit':
145
+ return { ...none, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true },
146
+ evidence: [observation('poc-exit', 'verifier live PoC run', { reason })],
147
+ reason: 'the PoC exited 0 against the caller-provided target under the older confined runner; an exit code is not trusted runtime proof, so this is not carried forward as confirmed' };
148
+ case 'verified-by-llm':
149
+ return { ...none, outcome: 'inconclusive', attempt: 1, evidence: [inference('llm-accept', 'layer-3 validator', { reason })],
150
+ reason: 'a model accepted the finding; a model verdict alone is an inference and never confirms' };
151
+ case 'verified-sanitizer-absence':
152
+ return { ...none, outcome: 'inconclusive', attempt: 1, oracle: SANITIZER_ORACLE, evidence: [observation('sanitizer-window', 'sanitizer-absence window scan', { reason })],
153
+ reason: `no sanitizer was found in the source window (${reason}); a static absence check does not execute anything` };
154
+ case 'unverified-by-design':
155
+ return { ...none, outcome: 'unsupported', attempt: 0, reason: `this vulnerability family has no verification oracle (${reason})` };
156
+ case 'cannot-verify': {
157
+ if (/^verifier-exception/.test(reason) || /^sandbox-error/.test(reason)) return { ...none, outcome: 'error', attempt: 1, reason };
158
+ if (/confinement primitive|disabled/.test(reason)) return { ...none, outcome: 'unsupported', attempt: 0, reason };
159
+ if (/^poc-timeout|^poc-exit/.test(reason)) return { ...none, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true }, reason: `${reason}: the PoC ran but did not demonstrate the effect; absence of proof is not refutation` };
160
+ if (/^no-poc-no-sanitizer-rule/.test(reason)) return { ...none, outcome: 'unsupported', attempt: 0, reason: 'neither a PoC nor a sanitizer rule exists for this finding, so no oracle applies' };
161
+ return { ...none, outcome: 'not-run', attempt: 0, reason: reason || 'the verifier did not run' };
162
+ }
163
+ default:
164
+ return { ...none, outcome: 'not-run', attempt: 0, reason: 'the finding carries no verifier verdict' };
165
+ }
166
+ }
167
+
168
+ function mapAutopilot(rec, ctx) {
169
+ const patch = ctx.patch && typeof ctx.patch === 'object' ? ctx.patch : null;
170
+ const repairStatus = rec?.applied === true ? 'applied' : patch ? 'proposed' : 'none';
171
+ const repair = patch ? { status: repairStatus, patchDigest: digestOf(patch) } : { status: 'none' };
172
+ const evidence = [observation('autopilot-stage', 'autopilot stage outcome', {
173
+ outcome: rec?.outcome ?? null, proofTier: rec?.proofTier ?? null, pocStillFires: rec?.pocStillFires ?? null, testsPass: rec?.testsPass ?? null, validation: rec?.validation ?? null,
174
+ })];
175
+ const scope = scopeOf('autopilot scan, prove, validate, fix, re-verify chain', null);
176
+ const base = { repair, evidence, scope, oracle: null, preconditions: { valid: false } };
177
+ if (rec?.validation === 'refuted') {
178
+ return { ...base, outcome: 'inconclusive', attempt: 1, evidence: [...evidence, inference('validator-refutation', 'autopilot validate stage', { validation: rec.validation })],
179
+ reason: 'a validator voted to refute the finding; a validator verdict is not an applicable oracle, so this is not a refutation' };
180
+ }
181
+ if (rec?.outcome === 'UNPROVEN') {
182
+ return rec.proofTier === 'proof-failed'
183
+ ? { ...base, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true }, reason: 'the proof-of-concept ran and did not demonstrate the effect; absence of proof is not refutation' }
184
+ : { ...base, outcome: 'not-run', attempt: 0, reason: String(rec.reason || 'no proof-of-concept ran for this finding') };
185
+ }
186
+ if (rec?.outcome === 'NO_FIX') return { ...base, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true }, reason: 'the finding was proved by the older runner but no patch was synthesised, so no re-verification ran' };
187
+ if (rec?.pocStillFires === true) return { ...base, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true }, reason: 'the proof-of-concept still fires against the patch under the older runner' };
188
+ if (rec?.outcome === 'VERIFIED_FIXED') return { ...base, outcome: 'inconclusive', attempt: 1, oracle: POC_ORACLE, preconditions: { valid: true }, reason: 'the proof-of-concept no longer fires against the patch under the older runner; that is not a trusted exploit-negative result' };
189
+ return { ...base, outcome: 'inconclusive', attempt: 1, reason: String(rec?.reason || 'the patch did not re-verify') };
190
+ }
191
+
192
+ function mapHunt(finding) {
193
+ const d = isPlainObject(finding?.discovery) ? finding.discovery : {};
194
+ const tier = d.confirmation?.tier || 'unconfirmed';
195
+ const scope = scopeOf(`hunt candidate from the ${d.lens || 'unknown'} lens (advisory)`, null);
196
+ const evidence = [];
197
+ if (d.refutation) evidence.push(inference('refutation-panel', 'refutation panel votes', d.refutation));
198
+ // X-205: a non-taint hypothesis (a tenant check, a privileged action, workflow order, replay, resource use) is not judged by
199
+ // a taint probe in EITHER direction. Without an executed oracle of its own class it is `not-run` (or `unsupported`).
200
+ const nonTaint = judgeNonTaintHypothesis({ finding, taint: { clean: !(tier === 'taint-confirmed' || tier === 'sink-adjacent') } });
201
+ if (nonTaint.nonTaint) {
202
+ return { outcome: nonTaint.outcome, attempt: 0, oracle: null, evidence, preconditions: { valid: false }, scope, reason: nonTaint.reason };
203
+ }
204
+ if (tier === 'taint-confirmed' || tier === 'sink-adjacent') {
205
+ evidence.unshift(observation('taint-probe', 'deterministic taint probe', d.confirmation));
206
+ return { outcome: 'inconclusive', attempt: 1, oracle: TAINT_ORACLE, evidence, preconditions: { valid: true }, scope,
207
+ reason: `the deterministic taint probe corroborated this candidate at tier '${tier}'; a static probe is corroboration, not execution` };
208
+ }
209
+ return { outcome: 'not-run', attempt: 0, oracle: null, evidence, preconditions: { valid: false }, scope,
210
+ reason: String(d.confirmation?.reason || 'the candidate was not corroborated by any deterministic probe; no oracle ran') };
211
+ }
212
+
213
+ const MAPPERS = Object.freeze({
214
+ 'fix-verify': (s, ctx) => mapFixVerify(s, ctx),
215
+ 'fix-verify-loop': (s, ctx) => mapFixLoop(s, ctx),
216
+ 'execution-proof': (s) => mapExecutionProof(s),
217
+ verifier: (s) => mapVerifierVerdict(s),
218
+ autopilot: (s, ctx) => mapAutopilot(s, ctx),
219
+ hunt: (s) => mapHunt(s),
220
+ });
221
+
222
+ /**
223
+ * Emit the version-1 verification record for one surface result.
224
+ *
225
+ * @param {string} surface one of SURFACES
226
+ * @param {object} source the surface's native result (see each mapper)
227
+ * @param {object} [ctx] { finding, hypothesisId, commit, scanRoot, files, patch, detectorOrigin }
228
+ * `oracle` emissions pass `ctx.fields`: the record fields the oracle runner
229
+ * built (including trusted-runner evidence). Nothing else can supply them.
230
+ * @returns {{ ok: boolean, record: object|null, legacy: object|null, errors: object[] }} never throws
231
+ */
232
+ export function emitVerification(surface, source, ctx = {}) {
233
+ try {
234
+ if (!SURFACES.includes(surface)) return fail('UNKNOWN_ENUM', `unknown verification surface '${surface}'`);
235
+ const finding = ctx.finding || (surface === 'execution-proof' || surface === 'verifier' || surface === 'hunt' ? source : null);
236
+ const hypothesisId = ctx.hypothesisId || ctx.originalFindingStableId || hypothesisIdFromFinding(finding);
237
+ if (!hypothesisId) return fail('MISSING_FIELD', 'a verification record needs a hypothesis id (a finding stableId); it is not guessed');
238
+ const commit = ctx.commit !== undefined ? (isCommit(ctx.commit) ? ctx.commit : null) : headCommit(ctx.scanRoot);
239
+ let fields;
240
+ if (surface === 'oracle') {
241
+ if (!isPlainObject(ctx.fields)) return fail('MISSING_FIELD', 'an oracle emission needs the fields built by the oracle runner');
242
+ fields = ctx.fields;
243
+ } else {
244
+ fields = MAPPERS[surface](source, ctx);
245
+ }
246
+ const record = buildVerificationRecord({
247
+ ...fields, hypothesisId, commit, detectorOrigin: detectorOriginOf(finding, ctx),
248
+ });
249
+ const v = validateVerificationRecord(record);
250
+ if (!v.ok) return { ok: false, record: null, legacy: null, errors: v.errors };
251
+ return { ok: true, record, legacy: toLegacyVerificationView(record), errors: [] };
252
+ } catch (e) {
253
+ return fail('RULE_VIOLATION', `verification record emission failed: ${String(e?.message || e).slice(0, 200)}`);
254
+ }
255
+ }
256
+
257
+ function fail(code, message) { return { ok: false, record: null, legacy: null, errors: [{ code, path: '', message }] }; }
@@ -0,0 +1,211 @@
1
+ // Promoting an advisory hypothesis into a finding (X-207.AC02, AC03).
2
+ //
3
+ // A hunt hypothesis is advisory. It becomes a FINDING only through `promoteHypothesis`, which needs all three of:
4
+ //
5
+ // 1. a trusted verifier receipt, VERIFIED here (not merely present): issued by `runOracle` in this process
6
+ // (`isIssuedReceipt`: a copy, a JSON round trip or a hand-built object is not), bound to this hypothesis and one exact
7
+ // commit, carrying a valid version-1 record whose id is the receipt's `recordId`, settled `confirmed` at the
8
+ // `runtime-confirmed` level with trusted-runtime-proof evidence from the trusted runner;
9
+ // 2. an explicit policy evaluation. The caller passes a policy; the result carries every rule that was evaluated with its
10
+ // outcome and a digest of the policy. The mandatory rules cannot be removed by a policy, and a policy key this module does
11
+ // not know is refused (fail closed);
12
+ // 3. an audit record, returned for EVERY decision (promoted or not), that links the source hypothesis to the promoted finding
13
+ // (or to none) and names the receipt, the record and the policy evaluation. It carries no clock, so identical inputs give
14
+ // an identical record.
15
+ //
16
+ // What can never satisfy the policy: a confidence score, a repeated or unanimous model agreement, a refutation panel's votes,
17
+ // a taint-probe tier, or a severity. None of them is an input to any rule except the optional `minConfidence` / `minAgreement`
18
+ // rules, which can only ADD a requirement: the receipt rules are always evaluated, so a hypothesis with perfect confidence and
19
+ // no receipt is rejected, and a policy made only of those rules still rejects it.
20
+ //
21
+ // The producer of a hunt hypothesis and the verifier that confirms it must be different parties (`verification-separation.js`
22
+ // is reused for that check). The audit record is returned, not persisted: persisting an audit trail needs an artifact-registry
23
+ // classification and a signing domain, which are not decided here.
24
+ import { isIssuedReceipt, ORACLE_CLASSES } from '../oracles/oracle.js';
25
+ import { validateVerificationRecord } from '../assurance/verification-record.js';
26
+ import { digestOf, hypothesisIdFromFinding, semanticId } from '../assurance/identity.js';
27
+ import { isPlainObject } from '../assurance/schema-kit.js';
28
+ import { recordProducer, assertSeparation } from '../verification-separation.js';
29
+ import { isAdvisoryHypothesis } from './advisory-state.js';
30
+
31
+ export const AUDIT_SCHEMA = 'agentic-security/promotion-audit';
32
+ const AUDIT_VERSION = '1.0.0';
33
+ export const POLICY_SCHEMA = 'agentic-security/promotion-policy';
34
+
35
+ export const DECISIONS = Object.freeze(['promoted', 'rejected', 'unsupported', 'inconclusive']);
36
+ const SEVERITY_ORDER = ['info', 'low', 'medium', 'high', 'critical'];
37
+
38
+ /** The default policy: runtime-confirmed by a class oracle other than the functional-regression one, severity capped at high. */
39
+ export const DEFAULT_PROMOTION_POLICY = Object.freeze({
40
+ schema: POLICY_SCHEMA, id: 'default', version: '1',
41
+ allowedOracleClasses: Object.freeze(ORACLE_CLASSES.filter((c) => c !== 'functional-regression')),
42
+ maxSeverity: 'high',
43
+ });
44
+
45
+ const POLICY_KEYS = ['schema', 'id', 'version', 'allowedOracleClasses', 'maxSeverity', 'expectedCommit', 'minConfidence', 'minAgreement'];
46
+
47
+ const PROMOTED = new WeakSet();
48
+ /** True only for a finding `promoteHypothesis` itself returned. */
49
+ export function isPromotedFinding(f) { return isPlainObject(f) && PROMOTED.has(f); }
50
+
51
+ function validatePolicy(policy) {
52
+ if (!isPlainObject(policy)) return ['no policy was supplied: promotion needs an explicit policy evaluation'];
53
+ const errs = [];
54
+ for (const k of Object.keys(policy)) if (!POLICY_KEYS.includes(k)) errs.push(`unknown policy key '${k}'`);
55
+ if (policy.schema !== POLICY_SCHEMA) errs.push(`policy.schema must be '${POLICY_SCHEMA}'`);
56
+ if (typeof policy.id !== 'string' || !policy.id) errs.push('policy.id is required');
57
+ if (typeof policy.version !== 'string' || !policy.version) errs.push('policy.version is required');
58
+ if (!Array.isArray(policy.allowedOracleClasses) || policy.allowedOracleClasses.length === 0 || !policy.allowedOracleClasses.every((c) => ORACLE_CLASSES.includes(c))) errs.push('policy.allowedOracleClasses must list known oracle classes');
59
+ if (!SEVERITY_ORDER.includes(policy.maxSeverity)) errs.push('policy.maxSeverity must be a severity');
60
+ if (policy.expectedCommit !== undefined && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(String(policy.expectedCommit))) errs.push('policy.expectedCommit must be an exact commit');
61
+ for (const k of ['minConfidence', 'minAgreement']) if (policy[k] !== undefined && !(typeof policy[k] === 'number' && policy[k] >= 0 && policy[k] <= 1)) errs.push(`policy.${k} must be a number between 0 and 1`);
62
+ return errs;
63
+ }
64
+
65
+ function agreementOf(h) {
66
+ const votes = h?.discovery?.refutation?.votes;
67
+ if (!Array.isArray(votes) || votes.length === 0) return null;
68
+ const upheld = votes.filter((v) => v && (v.verdict === 'upheld' || v.upheld === true)).length;
69
+ return upheld / votes.length;
70
+ }
71
+
72
+ /**
73
+ * Evaluate the policy for one hypothesis and one oracle result. Every rule is evaluated and listed.
74
+ * @returns {{ ok: boolean, rules: {id: string, pass: boolean, detail: string}[], status: string, policyDigest: string|null, record: object|null }}
75
+ */
76
+ export function evaluatePromotionPolicy({ hypothesis, result, policy } = {}) {
77
+ const rules = [];
78
+ const rule = (id, pass, detail) => rules.push({ id, pass: !!pass, detail });
79
+ const bad = validatePolicy(policy);
80
+ if (bad.length) {
81
+ rule('policy-valid', false, bad.join('; '));
82
+ return { ok: false, rules, status: 'rejected', policyDigest: null, record: null };
83
+ }
84
+ rule('policy-valid', true, `policy '${policy.id}' v${policy.version}`);
85
+ const policyDigest = digestOf(policy);
86
+
87
+ const receipt = result?.receipt ?? null;
88
+ const record = result?.record ?? null;
89
+ const hypothesisId = isPlainObject(hypothesis) ? hypothesisIdFromFinding(hypothesis) : null;
90
+
91
+ rule('hypothesis-advisory', isAdvisoryHypothesis(hypothesis) && !!hypothesisId, 'the source must be an unpromoted hunt hypothesis with a stable id');
92
+ const issued = isIssuedReceipt(receipt) && receipt.issuedBy === 'verifier' && receipt.schema === 'agentic-security/oracle-receipt';
93
+ rule('receipt-issued', issued, issued ? 'the receipt was issued by the verifier domain in this process' : 'no receipt issued by the verifier domain was supplied (a copy, a parsed file or a constructed object is not valid)');
94
+
95
+ const recordOk = isPlainObject(record) && validateVerificationRecord(record).ok;
96
+ rule('record-valid', recordOk, recordOk ? 'the verification record validates' : 'the verification record is missing or invalid');
97
+ const bound = issued && recordOk && receipt.recordId === record.id && receipt.request.hypothesisId === hypothesisId && record.hypothesisId === hypothesisId;
98
+ rule('receipt-binds-hypothesis', bound, bound ? 'the receipt, the record and the hypothesis name one another' : 'the receipt or record is for a different hypothesis, or they do not reference each other');
99
+ const commit = issued ? receipt.request.commit : null;
100
+ rule('commit-bound', !!commit && (!policy.expectedCommit || policy.expectedCommit === commit) && recordOk && record.commit === commit,
101
+ commit ? (policy.expectedCommit && policy.expectedCommit !== commit ? `the receipt is for a different commit than the policy expects` : 'the receipt is bound to one exact commit') : 'the receipt is not bound to an exact commit');
102
+
103
+ const outcome = bound ? receipt.settled.outcome : null;
104
+ rule('outcome-confirmed', outcome === 'confirmed', `the verifier settled '${outcome ?? 'nothing'}'`);
105
+ const runtime = bound && record.confirmationLevel === 'runtime-confirmed'
106
+ && record.evidence.some((e) => e.kind === 'trusted-runtime-proof' && e.producer === 'trusted-runner' && record.evidenceRefs.includes(e.id));
107
+ rule('runtime-confirmed', runtime, runtime ? 'trusted runtime proof from the trusted runner is referenced' : 'no referenced trusted runtime proof at the runtime-confirmed level');
108
+ const classOk = bound && policy.allowedOracleClasses.includes(receipt.oracle.class);
109
+ rule('class-allowed', classOk, classOk ? `oracle class '${receipt.oracle.class}' is allowed` : 'the oracle class is not allowed by the policy');
110
+
111
+ // producer and verifier must be different parties
112
+ const probe = { ...hypothesis };
113
+ let sep = { ok: false, reason: 'no receipt' };
114
+ if (bound) {
115
+ const stamped = recordProducer(probe, `hunter:${hypothesis?.discovery?.lens ?? 'unknown'}`);
116
+ sep = stamped.ok ? assertSeparation(probe, `verifier:oracle:${receipt.oracle.id}`) : stamped;
117
+ }
118
+ rule('producer-verifier-separation', sep.ok, sep.ok ? 'the hunter and the verifying oracle are different parties' : String(sep.reason));
119
+
120
+ // additive-only rules: they can add a requirement, never satisfy one
121
+ if (policy.minConfidence !== undefined) {
122
+ const c = typeof hypothesis?.confidence === 'number' ? hypothesis.confidence : null;
123
+ rule('min-confidence', c !== null && c >= policy.minConfidence, `an additional requirement only: confidence ${c ?? 'absent'} against ${policy.minConfidence}`);
124
+ }
125
+ if (policy.minAgreement !== undefined) {
126
+ const a = agreementOf(hypothesis);
127
+ rule('min-agreement', a !== null && a >= policy.minAgreement, `an additional requirement only: panel agreement ${a ?? 'absent'} against ${policy.minAgreement}`);
128
+ }
129
+
130
+ const ok = rules.every((r) => r.pass);
131
+ let status = 'promoted';
132
+ if (!ok) {
133
+ const settled = bound ? outcome : (isPlainObject(record) && typeof record.outcome === 'string' ? record.outcome : null);
134
+ if (settled === 'unsupported') status = 'unsupported';
135
+ else if (['inconclusive', 'not-run', 'error'].includes(settled)) status = 'inconclusive';
136
+ else status = 'rejected';
137
+ }
138
+ return { ok, rules, status, policyDigest, record: recordOk ? record : null };
139
+ }
140
+
141
+ const capSeverity = (sev, max) => (SEVERITY_ORDER.indexOf(sev) > SEVERITY_ORDER.indexOf(max) ? max : (SEVERITY_ORDER.includes(sev) ? sev : max));
142
+
143
+ /**
144
+ * Promote one hypothesis, or decline to. Never throws.
145
+ *
146
+ * @param {object} args
147
+ * @param {object} args.hypothesis a hunt finding (`discovery/judge.js` shape)
148
+ * @param {object} args.result the `runOracle` result for it (`{ record, receipt, outcome }`)
149
+ * @param {object} args.policy an explicit policy (see DEFAULT_PROMOTION_POLICY)
150
+ * @returns {{ ok: boolean, decision: string, finding: object|null, audit: object, evaluation: object }}
151
+ */
152
+ export function promoteHypothesis({ hypothesis, result, policy } = {}) {
153
+ let evaluation;
154
+ try { evaluation = evaluatePromotionPolicy({ hypothesis, result, policy }); }
155
+ catch (e) { evaluation = { ok: false, status: 'rejected', policyDigest: null, record: null, rules: [{ id: 'evaluation', pass: false, detail: `the policy evaluation failed: ${String(e?.message || e).slice(0, 120)}` }] }; }
156
+ const hypothesisId = isPlainObject(hypothesis) ? hypothesisIdFromFinding(hypothesis) : null;
157
+ const decision = evaluation.ok ? 'promoted' : evaluation.status;
158
+
159
+ let finding = null;
160
+ if (evaluation.ok) {
161
+ const h = hypothesis;
162
+ const base = {
163
+ id: `promoted-${hypothesisId}`, stableId: hypothesisId,
164
+ severity: capSeverity(h.severity, policy.maxSeverity),
165
+ file: h.file, line: h.line, vuln: h.vuln, cwe: h.cwe, description: h.description, remediation: h.remediation,
166
+ // A promoted finding is no longer a hunt hypothesis: it carries neither the hunt parser nor the `discovery` stamp, so the
167
+ // advisory exclusion does not apply to it, and its origin lives in `promotedFrom`.
168
+ parser: 'PROMOTED-HYPOTHESIS', family: h.family ?? 'other', ruleId: h.ruleId ?? 'promoted-hypothesis', snippet: h.snippet ?? '',
169
+ };
170
+ finding = { ...base, verificationRecord: JSON.parse(JSON.stringify(evaluation.record)) };
171
+ }
172
+
173
+ const audit = {
174
+ schema: AUDIT_SCHEMA, schemaVersion: AUDIT_VERSION, decision,
175
+ sourceHypothesis: {
176
+ id: hypothesisId, digest: digestOf(isPlainObject(hypothesis) ? hypothesis : null),
177
+ origin: { lens: hypothesis?.discovery?.lens ?? null, parser: hypothesis?.parser ?? null, family: hypothesis?.family ?? null },
178
+ },
179
+ promotedFinding: finding ? { stableId: finding.stableId, id: finding.id } : null,
180
+ verification: {
181
+ recordId: evaluation.record?.id ?? null, receiptDigest: isIssuedReceipt(result?.receipt) ? result.receipt.receiptDigest : null,
182
+ oracle: evaluation.record?.oracle ?? null, outcome: evaluation.record?.outcome ?? null,
183
+ confirmationLevel: evaluation.record?.confirmationLevel ?? null, commit: evaluation.record?.commit ?? null,
184
+ },
185
+ policy: { id: isPlainObject(policy) ? policy.id ?? null : null, version: isPlainObject(policy) ? policy.version ?? null : null, digest: evaluation.policyDigest, rules: evaluation.rules },
186
+ };
187
+ audit.id = semanticId('padt', audit, ['decision', 'sourceHypothesis', 'promotedFinding', 'verification', 'policy']);
188
+ deepFreeze(audit);
189
+ if (finding) {
190
+ finding.promotedFrom = { hypothesisId, auditId: audit.id, lens: hypothesis?.discovery?.lens ?? null };
191
+ deepFreeze(finding);
192
+ PROMOTED.add(finding);
193
+ }
194
+ return { ok: !!finding, decision, finding, audit, evaluation };
195
+ }
196
+
197
+ /** Do an audit record, the hypothesis it names and the promoted finding it names agree? (The link, checked from both ends.) */
198
+ export function auditLinksHold({ audit, hypothesis, finding }) {
199
+ if (!isPlainObject(audit) || audit.schema !== AUDIT_SCHEMA) return false;
200
+ const hid = isPlainObject(hypothesis) ? hypothesisIdFromFinding(hypothesis) : null;
201
+ if (!hid || audit.sourceHypothesis?.id !== hid || audit.sourceHypothesis?.digest !== digestOf(hypothesis)) return false;
202
+ if (audit.id !== semanticId('padt', audit, ['decision', 'sourceHypothesis', 'promotedFinding', 'verification', 'policy'])) return false;
203
+ if (audit.decision !== 'promoted') return finding == null && audit.promotedFinding === null;
204
+ return isPromotedFinding(finding) && finding.promotedFrom?.auditId === audit.id && finding.promotedFrom?.hypothesisId === hid
205
+ && audit.promotedFinding?.stableId === finding.stableId && finding.verificationRecord?.id === audit.verification.recordId;
206
+ }
207
+
208
+ function deepFreeze(o) {
209
+ for (const v of Object.values(o)) if (v && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v);
210
+ return Object.freeze(o);
211
+ }