@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +28 -3
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +272 -20
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/lineage/build-attested-correlation.js +63 -0
  157. package/src/lineage/deployment-identity.js +76 -0
  158. package/src/mcp/tools.js +13 -34
  159. package/src/pipeline/canonical-output.js +64 -0
  160. package/src/pipeline/detector-runner.js +7 -0
  161. package/src/pipeline/scan-health.js +14 -0
  162. package/src/pipeline/supervised-analyzers.js +125 -0
  163. package/src/pipeline/supervised-child.js +17 -0
  164. package/src/pipeline/telemetry.js +79 -0
  165. package/src/posture/accuracy-scorecard.js +98 -9
  166. package/src/posture/artifact-registry.js +5 -0
  167. package/src/posture/assurance/evidence-registry.js +215 -0
  168. package/src/posture/assurance/release-package-verify.js +135 -0
  169. package/src/posture/assurance/release-package.js +118 -0
  170. package/src/posture/assurance/release-verdict.js +94 -0
  171. package/src/posture/assurance/waiver.js +200 -0
  172. package/src/posture/compliance-truth.js +100 -0
  173. package/src/posture/evaluation/scan-child.js +3 -1
  174. package/src/posture/evaluation/why-missed.js +3 -1
  175. package/src/posture/exploitability-evidence.js +141 -0
  176. package/src/posture/fix-verify.js +4 -2
  177. package/src/posture/fleet/exposure-ranking.js +115 -0
  178. package/src/posture/fleet/service-chain-graph.js +150 -0
  179. package/src/posture/graph-reconciliation.js +154 -0
  180. package/src/posture/provenance/coverage.js +103 -0
  181. package/src/posture/provenance/git-evidence.js +84 -0
  182. package/src/posture/provenance/origin-resolver.js +7 -1
  183. package/src/posture/provenance/predicate-replay.js +14 -4
  184. package/src/posture/runtime-correlation.js +61 -15
  185. package/src/posture/runtime-explanation.js +116 -0
  186. package/src/posture/runtime-trace-ingest.js +194 -0
  187. package/src/posture/scan-checkpoint.js +38 -10
  188. package/src/posture/waf-ingest.js +7 -2
  189. package/src/report/index.js +4 -0
  190. package/src/report/oscal.js +38 -0
  191. package/src/report/perspectives.js +150 -0
  192. package/src/sast/_comment-strip.js +8 -1
  193. package/src/sast/ruby.js +26 -0
  194. package/src/util/string-memo.js +38 -0
@@ -0,0 +1,245 @@
1
+ // SemanticGraph v1: a typed, versioned, additive view over the Layer-1 IR (E2-01).
2
+ //
3
+ // WHY. The IR (`buildProjectIR`) is a bag of per-function records consumed by the taint engine. It has no stable identifiers, no shared
4
+ // entity types and no serialization contract, so every analysis that needs "the call at this place" or "where did this value come from"
5
+ // re-derives it with its own conventions, and nothing can be compared across runs. This module names the entities once, gives each a
6
+ // STABLE id, and fixes a versioned wire format.
7
+ //
8
+ // ADDITIVE. Building the graph reads the IR and never modifies it; it is not wired into findings, `chain[]` or last-scan.json. Existing
9
+ // output is unchanged by construction (a test pins that). A consumer opts in by calling `buildSemanticGraph`.
10
+ //
11
+ // HONEST. An entity type the IR cannot yet supply (Sanitizer, Guard, ServiceBoundary, StorageEdge) is declared in the schema, emitted as an
12
+ // empty list, and reported `schema-only` in `coverage` with the reason. A location the IR does not carry (column) is `null` and the entity
13
+ // says `locationPrecision: 'line'`. Nothing is guessed to fill a field.
14
+ //
15
+ // STABLE IDS. An id is the first 16 hex of SHA-256 over the entity's kind and its identity fields, which never include a line number: adding
16
+ // a blank line above a function does not change any id in it. Ties between otherwise identical entities (two `exec(x)` in one function) are
17
+ // broken by their order of appearance in that function.
18
+ import { createHash } from 'node:crypto';
19
+ import { analysisLimits } from '../dataflow/analysis-limits.js';
20
+
21
+ export const SEMANTIC_GRAPH_SCHEMA = 'agentic-security/semantic-graph';
22
+ export const SEMANTIC_GRAPH_VERSION = '1.0.0';
23
+ export const SUPPORTED_MAJOR = 1;
24
+
25
+ export const ENTITY_LISTS = Object.freeze(['symbols', 'callSites', 'allocations', 'fieldAccesses', 'valueOrigins', 'serviceBoundaries', 'storageEdges', 'sanitizers', 'guards']);
26
+ export const CONFIDENCE_TIERS = Object.freeze(['observed', 'inferred', 'unresolved']);
27
+ const SYMBOL_KINDS = Object.freeze(['module', 'function', 'class', 'variable', 'parameter']);
28
+ const ORIGIN_KINDS = Object.freeze(['parameter', 'call-result', 'literal', 'member', 'concat', 'object', 'array', 'function', 'identifier', 'unknown']);
29
+
30
+ const ID_PREFIX = Object.freeze({ symbols: 'sym', callSites: 'call', allocations: 'alloc', fieldAccesses: 'field', valueOrigins: 'orig', serviceBoundaries: 'svc', storageEdges: 'store', sanitizers: 'san', guards: 'guard' });
31
+
32
+ const h16 = (parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
33
+ const stripLine = (qid) => String(qid).replace(/@\d+$/, '');
34
+
35
+ // ---- canonical serialization ------------------------------------------------------------------------------------------------
36
+
37
+ function canon(v) {
38
+ if (Array.isArray(v)) return v.map(canon);
39
+ if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]));
40
+ return v;
41
+ }
42
+ export function serializeGraph(graph) {
43
+ const g = { ...graph };
44
+ for (const k of ENTITY_LISTS) g[k] = [...(graph[k] || [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
45
+ return JSON.stringify(canon(g));
46
+ }
47
+
48
+ // ---- validation -------------------------------------------------------------------------------------------------------------
49
+
50
+ /** @returns {{ok:boolean, errors:{code:string,path:string,message:string}[]}} Never throws on hostile input. */
51
+ export function validateGraph(g) {
52
+ const errors = [];
53
+ const err = (code, path, message) => errors.push({ code, path, message });
54
+ if (!g || typeof g !== 'object' || Array.isArray(g)) return { ok: false, errors: [{ code: 'NOT_AN_OBJECT', path: '', message: 'a graph is an object' }] };
55
+ if (g.schema !== SEMANTIC_GRAPH_SCHEMA) { err('SCHEMA_MISMATCH', 'schema', `expected ${SEMANTIC_GRAPH_SCHEMA}`); return { ok: false, errors }; }
56
+ const m = typeof g.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(g.schemaVersion) : null;
57
+ if (!m) { err('BAD_VERSION', 'schemaVersion', 'must be a semantic version'); return { ok: false, errors }; }
58
+ if (Number(m[1]) !== SUPPORTED_MAJOR) { err('UNSUPPORTED_MAJOR', 'schemaVersion', `major ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR}); migrate it first`); return { ok: false, errors }; }
59
+ const ids = new Map();
60
+ for (const list of ENTITY_LISTS) {
61
+ if (!Array.isArray(g[list])) { err('MISSING_FIELD', list, 'must be a list (empty when none)'); continue; }
62
+ g[list].forEach((e, i) => {
63
+ const p = `${list}[${i}]`;
64
+ if (!e || typeof e !== 'object' || Array.isArray(e)) { err('BAD_TYPE', p, 'must be an object'); return; }
65
+ if (typeof e.id !== 'string' || !new RegExp(`^${ID_PREFIX[list]}:[0-9a-f]{16}$`).test(e.id)) err('BAD_ID', `${p}.id`, `must look like ${ID_PREFIX[list]}:<16 hex>`);
66
+ else if (ids.has(e.id)) err('DUPLICATE_ID', `${p}.id`, `duplicate id ${e.id}`); else ids.set(e.id, list);
67
+ if (typeof e.file !== 'string' || !e.file || e.file.startsWith('/') || e.file.split('/').includes('..')) err('BAD_TYPE', `${p}.file`, 'must be a repository-relative path');
68
+ if (!(Number.isInteger(e.line) && e.line >= 0)) err('BAD_TYPE', `${p}.line`, 'line must be a non-negative integer');
69
+ if (!(e.column === null || (Number.isInteger(e.column) && e.column >= 0))) err('BAD_TYPE', `${p}.column`, 'column must be null or a non-negative integer');
70
+ if (e.column === null && e.locationPrecision !== 'line') err('RULE_VIOLATION', `${p}.locationPrecision`, "a null column requires locationPrecision 'line'");
71
+ const c = e.confidence;
72
+ if (!c || typeof c !== 'object' || !CONFIDENCE_TIERS.includes(c.tier) || typeof c.basis !== 'string' || !c.basis) err('BAD_TYPE', `${p}.confidence`, 'needs { tier, basis }');
73
+ });
74
+ }
75
+ if (errors.length) return { ok: false, errors };
76
+ const need = (e, p, field, list) => { if (e[field] !== null && e[field] !== undefined && ids.get(e[field]) !== list) err('DANGLING_REF', `${p}.${field}`, `${e[field]} is not a ${list} id`); };
77
+ g.symbols.forEach((e, i) => { if (!SYMBOL_KINDS.includes(e.kind)) err('UNKNOWN_ENUM', `symbols[${i}].kind`, `unknown symbol kind ${e.kind}`); need(e, `symbols[${i}]`, 'parent', 'symbols'); });
78
+ g.callSites.forEach((e, i) => { need(e, `callSites[${i}]`, 'caller', 'symbols'); need(e, `callSites[${i}]`, 'callee', 'symbols'); if (e.callee && e.resolution !== 'resolved') err('RULE_VIOLATION', `callSites[${i}].resolution`, 'a call with a callee must be resolved'); if (!e.callee && e.resolution === 'resolved') err('RULE_VIOLATION', `callSites[${i}].resolution`, 'a resolved call needs a callee'); (e.args || []).forEach((a, j) => need(a, `callSites[${i}].args[${j}]`, 'origin', 'valueOrigins')); });
79
+ g.valueOrigins.forEach((e, i) => { if (!ORIGIN_KINDS.includes(e.kind)) err('UNKNOWN_ENUM', `valueOrigins[${i}].kind`, `unknown origin kind ${e.kind}`); need(e, `valueOrigins[${i}]`, 'symbol', 'symbols'); });
80
+ g.fieldAccesses.forEach((e, i) => { need(e, `fieldAccesses[${i}]`, 'symbol', 'symbols'); if (!['read', 'write'].includes(e.mode)) err('UNKNOWN_ENUM', `fieldAccesses[${i}].mode`, 'mode must be read or write'); });
81
+ g.allocations.forEach((e, i) => need(e, `allocations[${i}]`, 'symbol', 'symbols'));
82
+ if (!g.coverage || typeof g.coverage !== 'object') err('MISSING_FIELD', 'coverage', 'coverage is required');
83
+ else for (const list of ENTITY_LISTS) { const c = g.coverage[list]; if (!c || !['populated', 'schema-only'].includes(c.state) || !c.reason) err('BAD_TYPE', `coverage.${list}`, 'needs { state, reason }'); else if (c.state === 'schema-only' && g[list].length) err('RULE_VIOLATION', `coverage.${list}`, 'schema-only but not empty'); else if (c.state === 'populated' && !g[list].length) err('RULE_VIOLATION', `coverage.${list}`, 'populated but empty'); }
84
+ return { ok: errors.length === 0, errors };
85
+ }
86
+
87
+ /** Parse and validate a serialized graph. Returns { ok, graph?, errors }. */
88
+ export function parseGraph(text) {
89
+ let g; try { g = JSON.parse(text); } catch (e) { return { ok: false, errors: [{ code: 'BAD_JSON', path: '', message: e.message }] }; }
90
+ const v = validateGraph(g);
91
+ return v.ok ? { ok: true, graph: g, errors: [] } : { ok: false, errors: v.errors };
92
+ }
93
+
94
+ /** Bring an older-minor graph to this version. A different major is refused: there is no silent best-effort. */
95
+ export function migrateGraph(g) {
96
+ const m = typeof g?.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(g.schemaVersion) : null;
97
+ if (!m || g.schema !== SEMANTIC_GRAPH_SCHEMA) return { ok: false, reason: 'not a semantic graph' };
98
+ if (Number(m[1]) !== SUPPORTED_MAJOR) return { ok: false, reason: `major ${m[1]} has no migration path to ${SEMANTIC_GRAPH_VERSION}` };
99
+ return { ok: true, graph: { ...g, schemaVersion: SEMANTIC_GRAPH_VERSION }, migrated: g.schemaVersion !== SEMANTIC_GRAPH_VERSION };
100
+ }
101
+
102
+ // ---- builder ----------------------------------------------------------------------------------------------------------------
103
+
104
+ const exprText = (e) => {
105
+ if (!e || typeof e !== 'object') return '';
106
+ switch (e.kind) {
107
+ case 'ident': return e.name;
108
+ case 'member': return `${exprText(e.object)}.${e.prop ?? '[]'}`;
109
+ case 'call': return `${exprText(e.callee)}(...)`;
110
+ case 'literal': return JSON.stringify(e.value);
111
+ default: return `<${e.kind || 'unknown'}>`;
112
+ }
113
+ };
114
+ const originKindOf = (e, params) => {
115
+ if (!e) return 'unknown';
116
+ if (e.kind === 'ident') return params.includes(e.name) ? 'parameter' : 'identifier';
117
+ if (e.kind === 'call') return 'call-result';
118
+ if (e.kind === 'literal') return 'literal';
119
+ if (e.kind === 'member') return 'member';
120
+ if (e.kind === 'binary' || e.kind === 'tpl') return 'concat';
121
+ if (e.kind === 'object') return 'object';
122
+ if (e.kind === 'array') return 'array';
123
+ if (e.kind === 'function-value') return 'function';
124
+ return 'unknown';
125
+ };
126
+ const loc = (file, line) => ({ file, line: Number.isInteger(line) ? line : 0, column: null, locationPrecision: 'line' });
127
+ const OBSERVED = { tier: 'observed', basis: 'read directly from the Layer-1 IR' };
128
+ const UNRESOLVED = { tier: 'unresolved', basis: 'the IR holds no resolved target for this call' };
129
+
130
+ function memberChain(e) {
131
+ const path = [];
132
+ let cur = e;
133
+ while (cur && cur.kind === 'member') { path.unshift(cur.prop ?? '[]'); cur = cur.object; }
134
+ return cur && cur.kind === 'ident' ? { base: cur.name, path } : null;
135
+ }
136
+ function walkExprs(e, visit) {
137
+ if (!e || typeof e !== 'object') return;
138
+ visit(e);
139
+ for (const k of ['object', 'callee', 'left', 'right', 'test', 'cons', 'alt']) if (e[k]) walkExprs(e[k], visit);
140
+ for (const k of ['args', 'parts', 'elements', 'values']) if (Array.isArray(e[k])) e[k].forEach((x) => walkExprs(x, visit));
141
+ if (Array.isArray(e.props)) e.props.forEach((p) => walkExprs(p?.value ?? p, visit));
142
+ }
143
+
144
+ /**
145
+ * @param {{perFile:object, callGraph?:object}} projectIR the object `buildProjectIR` returns
146
+ * @param {{engineVersion?:string}} [meta]
147
+ */
148
+ export function buildSemanticGraph(projectIR, meta = {}) {
149
+ const g = Object.fromEntries(ENTITY_LISTS.map((k) => [k, []]));
150
+ const ordinal = new Map();
151
+ const next = (key) => { const n = ordinal.get(key) ?? 0; ordinal.set(key, n + 1); return n; };
152
+ const symByQid = new Map();
153
+ const files = Object.keys(projectIR?.perFile || {}).sort();
154
+
155
+ // pass 1: symbols
156
+ for (const file of files) {
157
+ const pf = projectIR.perFile[file];
158
+ for (const fn of pf.functions || []) {
159
+ const isModule = fn.name === '<module>';
160
+ const key = `${file}|${stripLine(fn.qid)}`;
161
+ const id = `sym:${h16(['symbol', file, stripLine(fn.qid), next(`sym|${key}`)])}`;
162
+ symByQid.set(fn.qid, id);
163
+ g.symbols.push({ id, kind: isModule ? 'module' : 'function', name: fn.name, qid: stripLine(fn.qid), parent: null, params: [...(fn.params || [])], ...loc(file, fn.line), confidence: OBSERVED });
164
+ }
165
+ }
166
+ // function-by-name index for DIRECT same-file resolution only; anything else stays unresolved (cross-file resolution is E2-02)
167
+ const byFileName = new Map();
168
+ for (const file of files) for (const fn of projectIR.perFile[file].functions || []) if (fn.name !== '<module>') { const k = `${file}|${fn.name}`; byFileName.set(k, byFileName.has(k) ? null : symByQid.get(fn.qid)); }
169
+
170
+ // pass 2: call sites, origins, field accesses, allocations
171
+ for (const file of files) {
172
+ for (const fn of projectIR.perFile[file].functions || []) {
173
+ const symId = symByQid.get(fn.qid);
174
+ const params = fn.params || [];
175
+ const originFor = (expr, variable, line) => {
176
+ const kind = originKindOf(expr, params);
177
+ const o = { id: `orig:${h16(['origin', symId, variable, kind, exprText(expr), next(`orig|${symId}|${variable}|${kind}|${exprText(expr)}`)])}`, symbol: symId, variable, kind, text: exprText(expr), ...loc(file, line), confidence: kind === 'unknown' ? { tier: 'unresolved', basis: 'the IR could not model this expression' } : OBSERVED };
178
+ g.valueOrigins.push(o);
179
+ return o.id;
180
+ };
181
+ const field = (expr, mode, line) => {
182
+ const mc = memberChain(expr);
183
+ if (!mc || !mc.path.length) return;
184
+ g.fieldAccesses.push({ id: `field:${h16(['field', symId, mc.base, mc.path, mode, next(`field|${symId}|${mc.base}|${mc.path.join('.')}|${mode}`)])}`, symbol: symId, base: mc.base, path: mc.path, mode, ...loc(file, line), confidence: OBSERVED });
185
+ };
186
+ const alloc = (expr, line) => {
187
+ const kind = expr.kind === 'object' ? 'object' : expr.kind === 'array' ? 'array' : expr.kind === 'function-value' ? 'function' : null;
188
+ if (!kind) return;
189
+ g.allocations.push({ id: `alloc:${h16(['alloc', symId, kind, next(`alloc|${symId}|${kind}`)])}`, symbol: symId, kind, ...loc(file, line), confidence: OBSERVED });
190
+ };
191
+ for (const c of fn.calls || []) {
192
+ const calleeText = c.callee;
193
+ const direct = byFileName.get(`${file}|${calleeText}`) || null;
194
+ const args = (c.args || []).map((a, index) => { walkExprs(a, (x) => { if (x.kind === 'member') field(x, 'read', c.line); alloc(x, c.line); }); return { index, origin: originFor(a, `arg:${calleeText}:${index}`, c.line) }; });
195
+ g.callSites.push({ id: `call:${h16(['call', symId, calleeText, next(`call|${symId}|${calleeText}`)])}`, caller: symId, calleeText, callee: direct, resolution: direct ? 'resolved' : 'unresolved', args, ...loc(file, c.line), confidence: direct ? { tier: 'inferred', basis: 'a function of this name exists in the same file' } : UNRESOLVED });
196
+ }
197
+ const callFromExpr = (x, line) => {
198
+ const calleeText = exprText(x.callee).replace(/\(\.\.\.\)$/, '');
199
+ const direct = x.callee?.kind === 'ident' ? (byFileName.get(`${file}|${calleeText}`) || null) : null;
200
+ const args = (x.args || []).map((a, index) => ({ index, origin: originFor(a, `arg:${calleeText}:${index}`, line) }));
201
+ g.callSites.push({ id: `call:${h16(['call', symId, calleeText, next(`call|${symId}|${calleeText}`)])}`, caller: symId, calleeText, callee: direct, resolution: direct ? 'resolved' : 'unresolved', args, ...loc(file, line), confidence: direct ? { tier: 'inferred', basis: 'a function of this name exists in the same file' } : UNRESOLVED });
202
+ };
203
+ // The CFG is the one structure every language's IR shares (`writes` exists for JavaScript only): assignment nodes carry { target, source, line }.
204
+ const assigns = Object.values(fn.cfg?.nodes || {}).filter((n) => n.kind === 'assign' && n.target).sort((x, y) => (x.line ?? 0) - (y.line ?? 0) || String(x.id ?? '').localeCompare(String(y.id ?? '')));
205
+ for (const d of assigns) {
206
+ const variable = String(d.target);
207
+ const line = d.line ?? fn.line;
208
+ originFor(d.source, variable, line);
209
+ // calls that appear INSIDE an assignment source (`const cmd = h.build(x)`) are not in fn.calls; they are call sites all the same
210
+ walkExprs(d.source, (x) => { if (x.kind === 'member') field(x, 'read', line); alloc(x, line); if (x.kind === 'call' && x.callee) callFromExpr(x, line); });
211
+ const mc = variable.includes('.') ? variable.split('.') : null;
212
+ if (mc) g.fieldAccesses.push({ id: `field:${h16(['field', symId, mc[0], mc.slice(1), 'write', next(`field|${symId}|${mc[0]}|${mc.slice(1).join('.')}|write`)])}`, symbol: symId, base: mc[0], path: mc.slice(1), mode: 'write', ...loc(file, line), confidence: OBSERVED });
213
+ }
214
+ }
215
+ }
216
+
217
+ // E2-07: service relationships are opt-in. A caller that ran dataflow/service-edges.js passes the result as `meta.serviceEdges`; HTTP and
218
+ // messaging relationships become `serviceBoundaries`, persistence relationships become `storageEdges`, and relationships with no counterpart
219
+ // are kept as boundaries with `to: null` and unresolved confidence. Without it both lists stay schema-only, as before.
220
+ const se = meta.serviceEdges && meta.serviceEdges.optedIn ? meta.serviceEdges : null;
221
+ if (se) {
222
+ const tier = (e) => (e === 'observed' ? 'observed' : e === 'inferred' ? 'inferred' : 'unresolved');
223
+ for (const e of se.edges) {
224
+ const list = e.kind === 'persistence' ? g.storageEdges : g.serviceBoundaries;
225
+ list.push({ id: `${e.kind === 'persistence' ? 'store' : 'svc'}:${e.id.slice('svcedge:'.length)}`, kind: e.kind, from: e.from, to: e.to, via: e.via, ...loc(e.from.file, e.from.line), evidence: e.evidence, confidence: { tier: tier(e.evidence), basis: e.basis } });
226
+ }
227
+ for (const u of se.unknown) g.serviceBoundaries.push({ id: `svc:${h16(['unknown', u.service, u.file, u.kind, u.reason])}`, kind: u.kind, from: { service: u.service, file: u.file, line: u.line }, to: null, via: null, ...loc(u.file, u.line), evidence: 'unknown', confidence: { tier: 'unresolved', basis: u.reason } });
228
+ }
229
+ const SCHEMA_ONLY = {
230
+ ...(se ? {} : { serviceBoundaries: 'opt-in: pass meta.serviceEdges (dataflow/service-edges.js) with two or more declared services; the IR itself carries no service-boundary facts', storageEdges: 'opt-in: pass meta.serviceEdges (dataflow/service-edges.js); the IR itself carries no persistence facts' }),
231
+ sanitizers: 'sanitizer decisions are made by the dataflow layer (sanitizer-gate.js), not recorded in the IR; they are folded in by E2-08',
232
+ guards: 'guard dominance is computed by dataflow/guard-dominance.js, not recorded in the IR; it is folded in by E2-08',
233
+ };
234
+ const REASON_POP = { serviceBoundaries: 'HTTP and messaging relationships between declared services, and the ones with no counterpart', storageEdges: 'tables one declared service writes and another reads', symbols: 'one per IR function and per file top level', callSites: 'every call recorded in an IR function, plus calls nested inside assignment sources', allocations: 'object, array and function-valued expressions in call arguments and assignments', fieldAccesses: 'member reads in arguments and assignments, and dotted writes', valueOrigins: 'every call argument and every assignment node in the CFG of a function' };
235
+ const coverage = {};
236
+ for (const k of ENTITY_LISTS) coverage[k] = SCHEMA_ONLY[k] ? { state: 'schema-only', reason: SCHEMA_ONLY[k] } : g[k].length ? { state: 'populated', reason: REASON_POP[k] } : { state: 'schema-only', reason: `no ${k} in this input` };
237
+ const limits = [
238
+ 'columns are null: the IR carries line numbers only',
239
+ 'a call resolves only to a same-file function of the same name; imports, re-exports and methods are unresolved until E2-02',
240
+ 'heap, alias and closure facts are not in the IR, so field accesses are syntactic, not points-to results',
241
+ ];
242
+ // Dynamic constructs the analysis cannot resolve, per function (E2-03): the graph says where precision was given up, instead of leaving it implicit.
243
+ const dynamicFeatures = analysisLimits(projectIR).map((l) => ({ symbol: symByQid.get(`${l.qid}@${(projectIR.perFile[l.file].functions.find((f) => stripLine(f.qid) === l.qid) || {}).line}`) ?? null, file: l.file, line: l.line, kind: l.kind, detail: l.detail, effect: l.effect }));
244
+ return { schema: SEMANTIC_GRAPH_SCHEMA, schemaVersion: SEMANTIC_GRAPH_VERSION, producer: { layer: 'ir-v1', engineVersion: meta.engineVersion ?? null }, ...g, coverage, limits, dynamicFeatures };
245
+ }
@@ -0,0 +1,63 @@
1
+ // Runtime correlation that knows which build it is talking about (E5-02).
2
+ //
3
+ // `correlateObservations` (observation-correlation.js) is pinned, byte for byte, by its own tests: it answers "was this flow seen in the window" and
4
+ // nothing about WHICH build was seen. This wrapper adds that, without changing it. A caller that names the static snapshot's identity gets back the
5
+ // same correlation, computed over only the observations that could be about that code, plus a statement per flow of whether the evidence is tied to
6
+ // the snapshot's build:
7
+ //
8
+ // corroborated every contributing observation matched the snapshot's identity (deployment-identity.js: matched)
9
+ // unattested at least one contributing observation carried no comparable identity: seen at runtime, but not shown to be this code
10
+ // none no contributing observation
11
+ //
12
+ // Observations from a DIFFERENT build are excluded and listed under `buildMismatchedObservationIds`: they are not evidence about this code at all.
13
+ // A caller that names no snapshot identity gets the original result unchanged.
14
+ import { execFileSync } from 'node:child_process';
15
+ import { correlateObservations } from './observation-correlation.js';
16
+ import { canCorroborate, linkObservationToSnapshot, normalizeIdentity, observationIdentity } from './deployment-identity.js';
17
+
18
+ const LAYER_RUNTIME_OBSERVED = 'runtime_observed';
19
+
20
+ /** The identity of the source snapshot at `root`: the git HEAD, whether the work tree is dirty, and anything the caller adds (image digest, build hash, environment). */
21
+ export function snapshotIdentityOf(root, extra = {}) {
22
+ let commit = null; let dirty = false;
23
+ try {
24
+ commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
25
+ dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).trim().length > 0;
26
+ } catch { commit = null; }
27
+ return { ...normalizeIdentity({ commit, dirty, ...extra }) };
28
+ }
29
+
30
+ /**
31
+ * @param {object} graph
32
+ * @param {object[]|null|undefined} observations as for correlateObservations
33
+ * @param {object} opts correlateObservations' options plus
34
+ * snapshotIdentity {commit, buildHash, imageDigest, sbomRef, environment, dirty} of the static snapshot. Without it the original result is returned unchanged.
35
+ * observationBuilds {[observationId]: {commit, buildHash, imageDigest, sbomRef}} identity the importer recorded for an observation
36
+ */
37
+ export function correlateWithBuild(graph, observations, opts = {}) {
38
+ const snapshot = opts.snapshotIdentity && typeof opts.snapshotIdentity === 'object' ? normalizeIdentity(opts.snapshotIdentity) : null;
39
+ if (!snapshot || !Array.isArray(observations)) return correlateObservations(graph, observations, opts);
40
+ const builds = opts.observationBuilds && typeof opts.observationBuilds === 'object' ? opts.observationBuilds : {};
41
+ const links = new Map(); const kept = []; const mismatched = [];
42
+ for (const obs of observations) {
43
+ const id = obs && typeof obs.id === 'string' ? obs.id : null;
44
+ const claimed = id && builds[id] ? { ...observationIdentity(obs), ...normalizeIdentity({ ...builds[id], environment: obs.environment }) } : observationIdentity(obs);
45
+ const link = linkObservationToSnapshot(claimed, snapshot);
46
+ if (id) links.set(id, link);
47
+ if (link.state === 'mismatched') { if (id) mismatched.push(id); continue; }
48
+ kept.push(obs);
49
+ }
50
+ const base = correlateObservations(graph, kept, opts);
51
+ const byFlow = {};
52
+ for (const [fid, entry] of Object.entries(base.byFlow || {})) {
53
+ const ids = entry.observationIds || [];
54
+ const corroboration = entry.layer !== LAYER_RUNTIME_OBSERVED || !ids.length ? 'none' : ids.every((i) => canCorroborate(links.get(i))) ? 'corroborated' : 'unattested';
55
+ byFlow[fid] = { ...entry, corroboration };
56
+ }
57
+ return {
58
+ ...base, byFlow,
59
+ buildMismatchedObservationIds: [...new Set(mismatched)].sort(),
60
+ buildLink: { requested: true, snapshot: { commit: snapshot.commit, buildHash: snapshot.buildHash, imageDigest: snapshot.imageDigest, environment: snapshot.environment, dirty: snapshot.dirty } },
61
+ limitations: [...(base.limitations || []), 'Observations from a different build were excluded; observations with no comparable build identity are kept but marked unattested, never corroborated.'],
62
+ };
63
+ }
@@ -0,0 +1,76 @@
1
+ // Tying runtime observations to the code that was deployed (E5-02).
2
+ //
3
+ // A runtime observation is evidence about ONE running build. A static finding is evidence about ONE source snapshot. The two are the same subject only
4
+ // when the build is known to have been made from that snapshot. Without that tie, "this route received traffic" says nothing about whether the code
5
+ // that received it contained the finding, and calling it corroboration would be a false claim.
6
+ //
7
+ // WHAT TIES THEM. Four identity fields, compared when both sides have them:
8
+ // commit the git commit (a full sha, or a prefix of at least 12 hex characters)
9
+ // buildHash a build or reproducibility hash
10
+ // imageDigest the container image digest (sha256:...)
11
+ // sbomRef an SBOM or artifact reference
12
+ // plus the environment the observation came from, which must equal the environment the snapshot's deployment record names when both are present.
13
+ //
14
+ // THE RULES (each is a test)
15
+ // mismatched a field both sides carry DIFFERS, or the environments differ. One disagreement is enough, however many fields agree.
16
+ // matched at least one STRONG field (commit, buildHash or imageDigest) is present on both sides and equal, and nothing disagrees
17
+ // unknown no strong field is present on both sides: the observation carries no identity, the snapshot carries none, or they carry different kinds
18
+ // A snapshot with uncommitted changes cannot be tied to a commit: a commit match is downgraded to `unknown` with that reason, because the code that was
19
+ // scanned is not the code the commit names.
20
+ // Only `matched` may be labelled corroborating. `unknown` is unattested evidence; `mismatched` is excluded and counted.
21
+ export const STRONG_FIELDS = Object.freeze(['commit', 'buildHash', 'imageDigest']);
22
+ export const IDENTITY_FIELDS = Object.freeze(['commit', 'buildHash', 'imageDigest', 'sbomRef']);
23
+ const ATTR = Object.freeze({ commit: 'deployment.commit', buildHash: 'deployment.build', imageDigest: 'deployment.image.digest', sbomRef: 'deployment.sbom.ref' });
24
+ export const IDENTITY_ATTRIBUTE_KEYS = Object.freeze(Object.values(ATTR));
25
+
26
+ const hex = (s) => (typeof s === 'string' && /^[0-9a-f]{12,64}$/i.test(s) ? s.toLowerCase() : null);
27
+ const digest = (s) => { const m = typeof s === 'string' ? /^(?:sha256:)?([0-9a-f]{64})$/i.exec(s) : null; return m ? `sha256:${m[1].toLowerCase()}` : null; };
28
+ const token = (s) => (typeof s === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(s) ? s : null);
29
+
30
+ /** Normalise an identity-bearing object ({commit, buildHash, imageDigest, sbomRef, environment, dirty}) to comparable values; an unusable value becomes null. */
31
+ export function normalizeIdentity(raw) {
32
+ const r = raw && typeof raw === 'object' ? raw : {};
33
+ return { commit: hex(r.commit), buildHash: token(r.buildHash), imageDigest: digest(r.imageDigest), sbomRef: token(r.sbomRef), environment: token(r.environment), dirty: r.dirty === true };
34
+ }
35
+
36
+ /** The identity an observation carries, from its approved attributes and its environment. */
37
+ export function observationIdentity(obs) {
38
+ const a = (obs && obs.attributes) || {};
39
+ return normalizeIdentity({ commit: a[ATTR.commit], buildHash: a[ATTR.buildHash], imageDigest: a[ATTR.imageDigest], sbomRef: a[ATTR.sbomRef], environment: obs && obs.environment });
40
+ }
41
+
42
+ const sameCommit = (a, b) => { const n = Math.min(a.length, b.length); return n >= 12 && a.slice(0, n) === b.slice(0, n); };
43
+
44
+ /**
45
+ * @param {object} observation a runtime observation (or an identity object)
46
+ * @param {object} snapshot the static snapshot's identity: {commit, buildHash, imageDigest, sbomRef, environment, dirty}
47
+ * @returns {{state:'matched'|'mismatched'|'unknown', compared:string[], agreed:string[], disagreed:string[], reasons:string[]}}
48
+ */
49
+ export function linkObservationToSnapshot(observation, snapshot) {
50
+ const o = observation && observation.attributes !== undefined ? observationIdentity(observation) : normalizeIdentity(observation);
51
+ const s = normalizeIdentity(snapshot);
52
+ const compared = []; const agreed = []; const disagreed = []; const reasons = [];
53
+ for (const f of IDENTITY_FIELDS) {
54
+ if (o[f] == null || s[f] == null) continue;
55
+ compared.push(f);
56
+ const eq = f === 'commit' ? sameCommit(o[f], s[f]) : o[f] === s[f];
57
+ (eq ? agreed : disagreed).push(f);
58
+ }
59
+ if (o.environment != null && s.environment != null) {
60
+ compared.push('environment');
61
+ if (o.environment === s.environment) agreed.push('environment'); else disagreed.push('environment');
62
+ }
63
+ if (disagreed.length) return { state: 'mismatched', compared, agreed, disagreed, reasons: disagreed.map((f) => `${f} differs between the observation and the snapshot`) };
64
+ const strong = agreed.filter((f) => STRONG_FIELDS.includes(f));
65
+ if (!strong.length) {
66
+ if (o.commit == null && o.buildHash == null && o.imageDigest == null) reasons.push('the observation carries no commit, build hash or image digest');
67
+ else if (s.commit == null && s.buildHash == null && s.imageDigest == null) reasons.push('the snapshot carries no commit, build hash or image digest');
68
+ else reasons.push('the observation and the snapshot carry different kinds of identity, so none can be compared');
69
+ return { state: 'unknown', compared, agreed, disagreed, reasons };
70
+ }
71
+ if (s.dirty && strong.length === 1 && strong[0] === 'commit') return { state: 'unknown', compared, agreed, disagreed, reasons: ['the snapshot has uncommitted changes: the code that was analysed is not the code the commit names'] };
72
+ return { state: 'matched', compared, agreed, disagreed, reasons: [`${strong.join(', ')} agree and nothing disagrees`] };
73
+ }
74
+
75
+ /** True only for a link that may be called corroboration. */
76
+ export const canCorroborate = (link) => !!link && link.state === 'matched';
package/src/mcp/tools.js CHANGED
@@ -28,6 +28,7 @@ import { redactString, redactFinding } from './redact.js';
28
28
  import { _remediationOf, normalizeFindings } from '../report/index.js';
29
29
  // X-206: the one projection of the verification record, shared with the JSON report, the text report and the autopilot response.
30
30
  import { verificationFields } from '../posture/verification/projection.js';
31
+ import { verifyPatch, additiveFields } from '../fix/verify-patch.js';
31
32
  import { boundaryFields } from '../lineage/deployment/projection.js';
32
33
  // Git-origin provenance (Finding Provenance M0/M1). Distinct from
33
34
  // `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
@@ -879,25 +880,14 @@ export const apply_fix = {
879
880
  }
880
881
  // Inline re-verify — the load-bearing gate. Must pass to write.
881
882
  let verdict;
883
+ let fixVerification = null;
882
884
  try {
883
885
  const _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
884
- if (process.env.AGENTIC_SECURITY_FIX_RUN_TESTS === '1') {
885
- // Addition #7 — connect the closed-loop verifier: add the project test
886
- // suite as a fourth verification leg (scan + lint + tests). Opt-in
887
- // because many repos have no runner and we must not fail-closed by
888
- // default. Normalized to the scan+lint verdict shape used below.
889
- const { verifyFixWithTests } = await import('../posture/fix-verify-loop.js');
890
- const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
891
- verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict, verificationRecord: t.verificationRecord ?? null };
892
- } else {
893
- const verifyFixCore = await getVerifyFixCore();
894
- verdict = await verifyFixCore({
895
- scanRoot: ctx.sessionRoot,
896
- originalFindingStableId: f.stableId,
897
- files: _files,
898
- fixMeta,
899
- });
900
- }
886
+ // One verifier for every route (E3-03). AGENTIC_SECURITY_FIX_RUN_TESTS used to swap in a second test runner here; verifyFix
887
+ // already carries the test leg, so the flag is now inert.
888
+ const central = await verifyPatch({ scanRoot: ctx.sessionRoot, finding: { id: f.id, stableId: f.stableId }, files: _files, fixMeta, route: 'mcp-apply-fix', mode: dry_run ? 'preview' : 'apply', verify: await getVerifyFixCore() });
889
+ verdict = central.verdict;
890
+ fixVerification = central.fixVerification;
901
891
  } catch (e) {
902
892
  return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
903
893
  }
@@ -909,6 +899,7 @@ export const apply_fix = {
909
899
  // X-201: the same version-1 verification record every surface emits (additive; null when none could be formed).
910
900
  verificationRecord: verdict.verificationRecord ?? null,
911
901
  ...verificationFields(verdict.verificationRecord),
902
+ ...(fixVerification ? additiveFields(fixVerification) : {}),
912
903
  };
913
904
  }
914
905
  // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
@@ -929,7 +920,7 @@ export const apply_fix = {
929
920
  }
930
921
  const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
931
922
  if (dry_run) {
932
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification };
923
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
933
924
  }
934
925
  const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
935
926
  if (approvalRefusal) return approvalRefusal;
@@ -960,7 +951,7 @@ export const apply_fix = {
960
951
  }
961
952
  let acceptance = null;
962
953
  try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
963
- return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification };
954
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
964
955
  }
965
956
 
966
957
  if (typeof f.fix?.replacement !== 'string') {
@@ -1118,21 +1109,8 @@ export const verify_fix = {
1118
1109
  // Best-effort: a missing/unsigned/tampered scan just means no PoC is
1119
1110
  // available to re-check, not a verify_fix failure — the rescan/lint/
1120
1111
  // tests legs below are independent of this and still apply.
1121
- let poc = null;
1122
- try {
1123
- const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
1124
- const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
1125
- if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
1126
- } catch { /* best-effort lookup; poc stays null */ }
1127
-
1128
- const verifyFixCore = await getVerifyFixCore();
1129
- const r = await verifyFixCore({
1130
- scanRoot: ctx.sessionRoot,
1131
- originalFindingStableId: stable_id,
1132
- files: confined,
1133
- poc,
1134
- fixMeta,
1135
- });
1112
+ const central = await verifyPatch({ scanRoot: ctx.sessionRoot, finding: { stableId: stable_id }, files: confined, fixMeta, route: 'mcp-verify-fix', mode: 'verify', verify: await getVerifyFixCore() });
1113
+ const r = central.verdict;
1136
1114
  return {
1137
1115
  _meta: META,
1138
1116
  ok: r.ok,
@@ -1154,6 +1132,7 @@ export const verify_fix = {
1154
1132
  // `not-run` when no exploit oracle executed against the patch, never a bare pass/fail.
1155
1133
  verificationRecord: r.verificationRecord ?? null,
1156
1134
  ...verificationFields(r.verificationRecord),
1135
+ ...additiveFields(central.fixVerification),
1157
1136
  };
1158
1137
  } catch (e) {
1159
1138
  return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
@@ -0,0 +1,64 @@
1
+ // Canonical scan output (E6-06).
2
+ //
3
+ // A scan result mixes two kinds of fact. Some are about the CODE: which findings exist, where, with what evidence. Those must be identical on every
4
+ // run over the same input, whatever order the files were read in, however many scans ran at once, and whenever it happened. The rest are about the
5
+ // RUN: when it started, how long each stage took, which stage finished first. Those differ every time and are not a defect.
6
+ //
7
+ // This module splits them:
8
+ // canonical findings (and the other finding-bearing lists) with volatile fields removed, numbers rounded to a fixed precision, object keys sorted,
9
+ // lists in a total order (file, line, vuln, stableId, id, then the serialized record as the last tiebreak). Hashed into `digest`.
10
+ // run timestamps, durations, telemetry, scan id; kept, separate, and never part of the digest
11
+ // provenance what produced the canonical part: scanner version, rule-pack hash, and each source of NONDETERMINISM that was active (a network feed,
12
+ // a model), named so a reader knows which results could legitimately differ
13
+ //
14
+ // The digest therefore answers one question: did two runs find the same things? A difference in `run` never changes it.
15
+ import { createHash } from 'node:crypto';
16
+ import { computeRulePackHash } from '../posture/deterministic.js';
17
+
18
+ const LISTS = Object.freeze(['findings', 'secrets', 'logicVulns', 'supplyChain']);
19
+ // fields inside a finding that describe the run, not the code
20
+ const VOLATILE_FINDING_KEYS = new Set(['detectedAt', 'scannedAt', 'ts', 'timestamp', 'elapsedMs', 'durationMs', 'scanId']);
21
+ const PRECISION = 6;
22
+
23
+ const round = (n) => (Number.isInteger(n) ? n : Math.round(n * 10 ** PRECISION) / 10 ** PRECISION);
24
+ function clean(v) {
25
+ if (Array.isArray(v)) return v.map(clean);
26
+ if (v && typeof v === 'object') {
27
+ const o = {};
28
+ for (const k of Object.keys(v).sort()) { if (VOLATILE_FINDING_KEYS.has(k) || v[k] === undefined) continue; o[k] = clean(v[k]); }
29
+ return o;
30
+ }
31
+ return typeof v === 'number' && Number.isFinite(v) ? round(v) : v;
32
+ }
33
+ const s = (x) => (x === undefined || x === null ? '' : String(x));
34
+ function order(a, b) {
35
+ return s(a.file).localeCompare(s(b.file), 'en') || (Number(a.line) || 0) - (Number(b.line) || 0) || s(a.vuln || a.title).localeCompare(s(b.vuln || b.title), 'en')
36
+ || s(a.stableId).localeCompare(s(b.stableId), 'en') || s(a.id).localeCompare(s(b.id), 'en') || JSON.stringify(a).localeCompare(JSON.stringify(b), 'en');
37
+ }
38
+
39
+ /** Sources of nondeterminism that are active for this process, named. */
40
+ export function nondeterminismSources(env = process.env) {
41
+ const out = [];
42
+ if (env.AGENTIC_SECURITY_LLM_ENDPOINT && env.AGENTIC_SECURITY_LLM_VALIDATE !== '0') out.push({ source: 'llm-validator', reason: 'a model endpoint is configured and the validator is on: its verdicts can differ between runs', model: env.AGENTIC_SECURITY_LLM_MODEL || null });
43
+ if (env.AGENTIC_SECURITY_DETERMINISTIC !== '1' && env.AGENTIC_SECURITY_OFFLINE !== '1') out.push({ source: 'network-feeds', reason: 'vulnerability and registry feeds are fetched and cached; their content changes over time' });
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * @param {object} scan a scan result
49
+ * @param {{env?:object, scannerVersion?:string}} opts
50
+ * @returns {{canonical:object, digest:string, run:object, provenance:object}}
51
+ */
52
+ export function canonicalizeScan(scan, opts = {}) {
53
+ const canonical = {};
54
+ for (const k of LISTS) if (Array.isArray(scan && scan[k])) canonical[k] = scan[k].map(clean).sort(order);
55
+ const digest = createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
56
+ const meta = (scan && (scan._scanMeta || scan.meta)) || {};
57
+ const run = {
58
+ scanId: meta.scanId ?? null, startedAt: meta.startedAt ?? null, durationMs: meta.durationMs ?? null,
59
+ telemetry: scan && scan.telemetry ? scan.telemetry : null,
60
+ fileTimings: Array.isArray(meta.fileTimings) ? meta.fileTimings : null,
61
+ };
62
+ const provenance = { scannerVersion: opts.scannerVersion ?? null, rulePackHash: computeRulePackHash(), nondeterminism: nondeterminismSources(opts.env), canonicalPrecision: PRECISION };
63
+ return { canonical, digest, run, provenance };
64
+ }
@@ -41,11 +41,18 @@
41
41
  * @param {() => any} fn - the detector call itself, e.g. `() => scanXxx(file, cc)`
42
42
  * @returns {any} fn()'s return value, or undefined if it threw
43
43
  */
44
+ // E6-01: an optional sink for per-detector timing. Unset (the default), runDetector does exactly what it did before; set by a scan for its duration.
45
+ let _timingSink = null;
46
+ export function setDetectorTimingSink(fn) { _timingSink = typeof fn === 'function' ? fn : null; }
47
+
44
48
  export function runDetector(detectorErrors, file, analyzer, fn) {
49
+ const t0 = _timingSink ? process.hrtime.bigint() : 0n;
45
50
  try {
46
51
  return fn();
47
52
  } catch (e) {
48
53
  detectorErrors.push({ file, analyzer, err: String((e && e.message) || e) });
49
54
  return undefined;
55
+ } finally {
56
+ if (_timingSink) _timingSink(analyzer, Number(process.hrtime.bigint() - t0) / 1e6);
50
57
  }
51
58
  }
@@ -55,12 +55,23 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
55
55
  if (filesTimedOut > 0) {
56
56
  conditions.push(`${filesTimedOut} file(s) exceeded the per-file analysis timeout`);
57
57
  }
58
+ // A file the engine does not read at all (over the size limit, or too dense to be source: minified or generated) is a file whose problems nobody looked for.
59
+ // It used to be counted in `files.skipped` and nowhere else, so a scan that read none of a repository's largest files still said `complete`. An empty file is
60
+ // also counted as skipped, but there is nothing in it to miss, so it is not a condition.
61
+ const unanalysed = (Number(scanMeta?.filesOversizeSkipped) || 0) + (Number(scanMeta?.filesDenseSkipped) || 0);
62
+ if (unanalysed > 0) {
63
+ conditions.push(`${unanalysed} file(s) were not analysed because they are over the size limit or too dense to be source (minified or generated)`);
64
+ }
58
65
  if (engineErrors?.cppDataflowParseErrors > 0) {
59
66
  conditions.push(`${engineErrors.cppDataflowParseErrors} C/C++ dataflow parse error(s)`);
60
67
  }
61
68
  if (deepStatus?.failure) {
62
69
  conditions.push(`deep analysis (IR-taint) threw and fell back to pattern-only results: ${deepStatus.failure}`);
63
70
  }
71
+ if (Array.isArray(deepStatus?.incomplete) && deepStatus.incomplete.length) {
72
+ // E2-05: the deep analysis ran but stopped short somewhere. The result is less complete than it looks, so it is a condition, not a footnote.
73
+ conditions.push(`deep analysis stopped short (analysis_incomplete): ${deepStatus.incomplete.map((x) => `${x.reason} x${x.count}`).join(', ')}`);
74
+ }
64
75
  if (deepStatus?.requested && !deepStatus.enabled) {
65
76
  conditions.push(`deep analysis was requested but did not run: ${deepStatus.reason || 'unknown reason'}`);
66
77
  }
@@ -111,6 +122,8 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
111
122
  expected: scanMeta?.checkpoint?.total ?? null,
112
123
  scanned: scanMeta?.filesScanned ?? null,
113
124
  skipped: (Number(scanMeta?.filesSkipped) || 0) + (Number(scanMeta?.filesDenseSkipped) || 0),
125
+ // skipped files that had something in them to miss: over the size limit, or too dense to be source. Empty files are in `skipped` and not here.
126
+ unanalysed,
114
127
  timedOut: filesTimedOut,
115
128
  },
116
129
  analyzers: analyzerCoverage || null,
@@ -122,6 +135,7 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
122
135
  ciOverrideAllowed: !!deepStatus.ciOverrideAllowed,
123
136
  reason: deepStatus.reason ?? null,
124
137
  failure: deepStatus.failure ?? null,
138
+ ...(Array.isArray(deepStatus.incomplete) && deepStatus.incomplete.length ? { incomplete: deepStatus.incomplete } : {}),
125
139
  }
126
140
  : null,
127
141
  lineageAnalysis: lineageStatus