@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 (201) hide show
  1. package/CHANGELOG.md +26 -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 +31 -6
  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 +282 -24
  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/language/haskell-findings.js +2 -0
  157. package/src/language/haskell-guards.js +1 -1
  158. package/src/language/haskell-ir.js +3 -1
  159. package/src/language/haskell-models.js +94 -0
  160. package/src/language/haskell-security-rules.js +20 -0
  161. package/src/language/nixos-hardening.js +72 -0
  162. package/src/lineage/build-attested-correlation.js +63 -0
  163. package/src/lineage/deployment-identity.js +76 -0
  164. package/src/mcp/tools.js +13 -34
  165. package/src/pipeline/canonical-output.js +64 -0
  166. package/src/pipeline/detector-runner.js +7 -0
  167. package/src/pipeline/scan-health.js +14 -0
  168. package/src/pipeline/supervised-analyzers.js +125 -0
  169. package/src/pipeline/supervised-child.js +17 -0
  170. package/src/pipeline/telemetry.js +79 -0
  171. package/src/posture/accuracy-scorecard.js +98 -9
  172. package/src/posture/artifact-registry.js +5 -0
  173. package/src/posture/assurance/evidence-registry.js +215 -0
  174. package/src/posture/assurance/release-package-verify.js +135 -0
  175. package/src/posture/assurance/release-package.js +118 -0
  176. package/src/posture/assurance/release-verdict.js +94 -0
  177. package/src/posture/assurance/waiver.js +200 -0
  178. package/src/posture/compliance-truth.js +100 -0
  179. package/src/posture/evaluation/scan-child.js +3 -1
  180. package/src/posture/evaluation/why-missed.js +3 -1
  181. package/src/posture/exploitability-evidence.js +141 -0
  182. package/src/posture/fix-verify.js +4 -2
  183. package/src/posture/fleet/exposure-ranking.js +115 -0
  184. package/src/posture/fleet/service-chain-graph.js +150 -0
  185. package/src/posture/graph-reconciliation.js +154 -0
  186. package/src/posture/provenance/coverage.js +103 -0
  187. package/src/posture/provenance/git-evidence.js +84 -0
  188. package/src/posture/provenance/origin-resolver.js +7 -1
  189. package/src/posture/provenance/predicate-replay.js +14 -4
  190. package/src/posture/runtime-correlation.js +61 -15
  191. package/src/posture/runtime-explanation.js +116 -0
  192. package/src/posture/runtime-trace-ingest.js +194 -0
  193. package/src/posture/scan-checkpoint.js +38 -10
  194. package/src/posture/waf-ingest.js +7 -2
  195. package/src/report/index.js +4 -0
  196. package/src/report/oscal.js +38 -0
  197. package/src/report/perspectives.js +150 -0
  198. package/src/sast/_comment-strip.js +8 -1
  199. package/src/sast/ruby.js +26 -0
  200. package/src/sast/zip-slip.js +79 -0
  201. package/src/util/string-memo.js +38 -0
@@ -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
@@ -0,0 +1,125 @@
1
+ // Supervised, resource-bounded analyzer execution (E6-05).
2
+ //
3
+ // A worker thread (analyzer-supervisor.js) can be terminated on a deadline, but it shares the process: it cannot be given a memory ceiling that
4
+ // does not take the scanner down with it, it cannot be CPU-limited, and anything it spawns outlives it. An analyzer that is expensive or runs
5
+ // code of uncertain behaviour belongs in its OWN PROCESS, and this module runs it there:
6
+ //
7
+ // * own process GROUP (detached), so a kill takes the analyzer and everything it forked; a test proves a forked grandchild dies too
8
+ // * wall-clock deadline, a resident-memory ceiling polled from outside (`maxRssMb`), a V8 heap ceiling (`maxHeapMb`, which turns exhaustion into an
9
+ // abort the supervisor classifies as `oom`), and an optional CPU-seconds ceiling (`cpuSeconds`, POSIX `ulimit -t`)
10
+ // * cancellation through an AbortSignal; a hard overall stop (`maxTotalMs`) so the run can never wait indefinitely
11
+ // * a BOUNDED queue: at most `concurrency` run at once, at most `maxQueue` wait, the rest are `rejected: queue_full`, never dropped silently
12
+ // * every task ends in exactly one terminal status. A failure is a result, not an exception: the other analyzers' results are kept
13
+ //
14
+ // `summarizeSupervised` turns the results into what the rest of the pipeline already understands: merged findings from the analyzers that
15
+ // completed, an `analyzerCoverage`-shaped object for computeScanHealth (so scan health degrades to `partial` and a strict assurance mode fails), and
16
+ // the exact list of analyzers that did not finish and why.
17
+ import { spawn } from 'node:child_process';
18
+ import { execFileSync } from 'node:child_process';
19
+ import * as path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const CHILD = path.join(HERE, 'supervised-child.js');
24
+ export const TERMINAL_STATUSES = Object.freeze(['completed', 'failed', 'timed_out', 'oom', 'rss_limit', 'cpu_limit', 'cancelled', 'rejected', 'overall_deadline']);
25
+ const DEFAULTS = Object.freeze({ concurrency: 2, maxQueue: 64, timeoutMs: 60_000, maxHeapMb: 1024, maxRssMb: 2048, cpuSeconds: 0, maxResultBytes: 8 * 1024 * 1024, maxTotalMs: 30 * 60_000, rssPollMs: 200 });
26
+
27
+ const killGroup = (pid) => { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } } };
28
+ const rssMb = (pid) => { try { return parseInt(execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim(), 10) / 1024; } catch { return 0; } };
29
+
30
+ function runOne(task, o, state) {
31
+ return new Promise((resolve) => {
32
+ const t0 = Date.now();
33
+ const lim = { timeoutMs: task.timeoutMs ?? o.timeoutMs, maxHeapMb: task.maxHeapMb ?? o.maxHeapMb, maxRssMb: task.maxRssMb ?? o.maxRssMb, cpuSeconds: task.cpuSeconds ?? o.cpuSeconds };
34
+ // limits become command-line text, so each must be a plain positive number before it goes anywhere near a command line
35
+ for (const [k, v] of Object.entries(lim)) {
36
+ if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) return resolve({ id: task.id, status: 'failed', ok: false, error: `invalid limit ${k}: must be a non-negative number`, durationMs: 0 });
37
+ }
38
+ const node = [`--max-old-space-size=${Math.ceil(lim.maxHeapMb)}`, CHILD];
39
+ const posix = process.platform !== 'win32';
40
+ // the CPU ceiling is applied with `ulimit` in a shell that then `exec`s node. The values are passed as positional parameters and quoted by the
41
+ // shell itself, so nothing from the task is ever parsed as shell syntax
42
+ const [cmd, args] = posix && lim.cpuSeconds > 0 ? ['/bin/sh', ['-c', 'ulimit -t "$1"; shift; exec "$@"', 'sh', String(Math.ceil(lim.cpuSeconds)), process.execPath, ...node]] : [process.execPath, node];
43
+ const child = spawn(cmd, args, { detached: posix, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { ...process.env, AGENTIC_SECURITY_SUPERVISED_SPEC: JSON.stringify({ modulePath: task.modulePath, exportName: task.exportName, args: task.args || [] }) } });
44
+ const pid = child.pid; state.pids.add(pid);
45
+ let status = null; let msg = null; let stderr = ''; let settled = false;
46
+ const done = (s, extra = {}) => {
47
+ if (settled) return; settled = true;
48
+ clearTimeout(timer); clearInterval(poll); state.kills.delete(kill);
49
+ killGroup(pid); state.pids.delete(pid);
50
+ resolve({ id: task.id, status: s, ok: s === 'completed', durationMs: Date.now() - t0, pid, ...extra });
51
+ };
52
+ const kill = (why) => { if (!status) status = why; killGroup(pid); };
53
+ state.kills.add(kill);
54
+ const timer = setTimeout(() => kill('timed_out'), lim.timeoutMs);
55
+ const poll = setInterval(() => { if (!status && lim.maxRssMb > 0 && rssMb(pid) > lim.maxRssMb) kill('rss_limit'); }, o.rssPollMs);
56
+ child.stderr.on('data', (d) => { if (stderr.length < 4000) stderr += d.toString(); });
57
+ child.on('message', (m) => { msg = m; });
58
+ child.on('error', (e) => done('failed', { error: `could not start: ${e.message}` }));
59
+ child.on('close', (code, signal) => {
60
+ if (status) return done(status, { error: status === 'timed_out' ? `exceeded ${lim.timeoutMs} ms` : status === 'rss_limit' ? `exceeded ${lim.maxRssMb} MB resident` : status, exitCode: code, signal });
61
+ if (msg && msg.ok) {
62
+ const bytes = Buffer.byteLength(JSON.stringify(msg.result ?? null));
63
+ return bytes > o.maxResultBytes ? done('failed', { error: `result of ${bytes} bytes exceeds the ${o.maxResultBytes} byte cap`, exitCode: code, signal }) : done('completed', { result: msg.result, exitCode: code, signal });
64
+ }
65
+ if (msg && !msg.ok) return done('failed', { error: msg.error, exitCode: code, signal });
66
+ if (/heap out of memory|allocation failed|ERR_WORKER_OUT_OF_MEMORY/i.test(stderr) || signal === 'SIGABRT' || code === 134) return done('oom', { error: 'ran out of memory', exitCode: code, signal });
67
+ if (signal === 'SIGXCPU' || (lim.cpuSeconds > 0 && (code === 152 || signal === 'SIGKILL'))) return done('cpu_limit', { error: `exceeded ${lim.cpuSeconds} CPU seconds`, exitCode: code, signal });
68
+ return done('failed', { error: `exited without a result (code ${code}, signal ${signal})${stderr ? `: ${stderr.trim().slice(0, 300)}` : ''}`, exitCode: code, signal });
69
+ });
70
+ });
71
+ }
72
+
73
+ /**
74
+ * @param {{id:string, modulePath:string, exportName:string, args?:any[], timeoutMs?:number, maxHeapMb?:number, maxRssMb?:number, cpuSeconds?:number}[]} tasks
75
+ * @param {{concurrency?, maxQueue?, timeoutMs?, maxHeapMb?, maxRssMb?, cpuSeconds?, maxResultBytes?, maxTotalMs?, signal?:AbortSignal}} opts
76
+ * @returns {Promise<object[]>} one result per task, in task order, each with exactly one terminal status
77
+ */
78
+ export async function runSupervised(tasks, opts = {}) {
79
+ const o = { ...DEFAULTS, ...opts };
80
+ const state = { pids: new Set(), kills: new Set() };
81
+ const results = new Array(tasks.length);
82
+ const queue = []; let running = 0; let stopped = null;
83
+ const stop = (why) => { if (stopped) return; stopped = why; for (const k of [...state.kills]) k(why); };
84
+ const onAbort = () => stop('cancelled');
85
+ if (o.signal) { if (o.signal.aborted) stopped = 'cancelled'; else o.signal.addEventListener('abort', onAbort, { once: true }); }
86
+ const overall = setTimeout(() => stop('overall_deadline'), o.maxTotalMs);
87
+ tasks.forEach((t, i) => {
88
+ if (queue.length >= o.maxQueue + o.concurrency) results[i] = { id: t.id, status: 'rejected', ok: false, error: `queue_full: more than ${o.maxQueue} waiting`, durationMs: 0 };
89
+ else queue.push(i);
90
+ });
91
+ await new Promise((resolve) => {
92
+ const pump = () => {
93
+ while (running < o.concurrency && queue.length) {
94
+ const i = queue.shift();
95
+ if (stopped) { results[i] = { id: tasks[i].id, status: stopped, ok: false, error: `not started: ${stopped}`, durationMs: 0 }; continue; }
96
+ running++;
97
+ runOne(tasks[i], o, state).then((r) => { results[i] = r; }, (e) => { results[i] = { id: tasks[i].id, status: 'failed', ok: false, error: String(e && e.message || e), durationMs: 0 }; }).finally(() => { running--; pump(); });
98
+ }
99
+ if (!running && !queue.length) resolve();
100
+ };
101
+ pump();
102
+ });
103
+ clearTimeout(overall); if (o.signal) o.signal.removeEventListener('abort', onAbort);
104
+ // a task killed by the overall stop or a cancel reports that, not 'failed'
105
+ return results.map((r) => (stopped && !r.ok && ['timed_out', 'failed'].includes(r.status) && r.error === stopped ? { ...r, status: stopped } : r));
106
+ }
107
+
108
+ /**
109
+ * What the rest of the pipeline needs from a supervised run.
110
+ * @returns {{findings:object[], completed:string[], incomplete:{id:string,status:string,error:string}[], analyzerCoverage:object, conditions:string[]}}
111
+ */
112
+ export function summarizeSupervised(results) {
113
+ const findings = []; const completed = []; const incomplete = [];
114
+ for (const r of results) {
115
+ if (r.status === 'completed') { completed.push(r.id); if (Array.isArray(r.result)) findings.push(...r.result); else if (r.result && Array.isArray(r.result.findings)) findings.push(...r.result.findings); }
116
+ else incomplete.push({ id: r.id, status: r.status, error: r.error || r.status });
117
+ }
118
+ const n = (s) => incomplete.filter((x) => x.status === s).length;
119
+ const analyzerCoverage = { analyzers: results.length, completed: completed.length, failed: incomplete.length, timedOut: n('timed_out') + n('overall_deadline'), outOfMemory: n('oom') + n('rss_limit'), cancelled: n('cancelled'), rejected: n('rejected'), skippedByPolicy: 0 };
120
+ const conditions = incomplete.map((x) => `analyzer ${x.id} did not finish (${x.status}): ${x.error}`);
121
+ return { findings, completed, incomplete, analyzerCoverage, conditions };
122
+ }
123
+
124
+ /** Is a process still alive? (test and diagnostics helper for "no orphan") */
125
+ export function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
@@ -0,0 +1,17 @@
1
+ // The child half of supervised-analyzers.js (E6-05). Runs ONE exported function in its own process and reports the result over the IPC channel. It is
2
+ // started by the supervisor, never imported: the supervisor owns the limits, the deadline and the kill, so a child that hangs, exhausts memory or
3
+ // forks more processes can be stopped from outside without its cooperation.
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ async function main() {
7
+ const spec = JSON.parse(process.env.AGENTIC_SECURITY_SUPERVISED_SPEC || '{}');
8
+ try {
9
+ const mod = await import(pathToFileURL(spec.modulePath).href);
10
+ if (typeof mod[spec.exportName] !== 'function') throw new Error(`${spec.exportName} is not an exported function of ${spec.modulePath}`);
11
+ const result = await mod[spec.exportName](...(spec.args || []));
12
+ process.send({ ok: true, result }, () => process.exit(0));
13
+ } catch (e) {
14
+ process.send({ ok: false, error: String((e && e.message) || e).slice(0, 2000) }, () => process.exit(1));
15
+ }
16
+ }
17
+ main();
@@ -0,0 +1,79 @@
1
+ // Scan telemetry (E6-01): where the time and memory of ONE scan went, and which stage failed, as structured aggregates inside the scan result.
2
+ //
3
+ // LOCAL ONLY. Nothing here sends anything anywhere: the snapshot is a field of the scan object the caller already holds. There is no endpoint, no
4
+ // identifier of the user or the machine, no file path and no source text; stage names are fixed strings and an error carries a class and a short
5
+ // message, never code.
6
+ //
7
+ // WHAT IT RECORDS
8
+ // stages wall time and call count per named stage (file read, detectors, deep analysis, lineage, each annotator, ...)
9
+ // detectors time and call count per detector, accumulated by runDetector
10
+ // memory resident-set and heap high-water marks sampled at stage boundaries (a sample, not a profiler: a spike between two samples is missed)
11
+ // counters named integer counts (files scanned, files skipped, cache hits and misses)
12
+ // errors attributed failures: the stage, a class, a short message and a count, from the existing error arrays
13
+ // overhead the time this module itself spent bookkeeping, so its cost is measured, not asserted
14
+ //
15
+ // DETERMINISM. Under --deterministic, every duration and memory figure is reported as 0 and the stage list is sorted by name, so two runs of the same
16
+ // input give byte-identical telemetry; the counts, which are properties of the input, are kept.
17
+ const MB = 1024 * 1024;
18
+
19
+ export function createTelemetry({ deterministic = false } = {}) {
20
+ const stages = new Map(); const detectors = new Map(); const counters = new Map(); const errors = new Map();
21
+ const mem = { rssHighWaterBytes: 0, heapHighWaterBytes: 0, samples: 0 };
22
+ let selfNs = 0n; const startedNs = process.hrtime.bigint();
23
+ const own = (fn) => { const a = process.hrtime.bigint(); try { return fn(); } finally { selfNs += process.hrtime.bigint() - a; } };
24
+ const bump = (map, key, ms) => own(() => { const c = map.get(key) || { ms: 0, calls: 0 }; c.ms += ms; c.calls += 1; map.set(key, c); });
25
+ const sample = () => own(() => {
26
+ if (deterministic) { mem.samples++; return; }
27
+ const m = process.memoryUsage();
28
+ if (m.rss > mem.rssHighWaterBytes) mem.rssHighWaterBytes = m.rss;
29
+ if (m.heapUsed > mem.heapHighWaterBytes) mem.heapHighWaterBytes = m.heapUsed;
30
+ mem.samples++;
31
+ });
32
+ const api = {
33
+ /** Time a synchronous function as stage `name`. Errors propagate; the time is still recorded. */
34
+ time(name, fn) { const a = process.hrtime.bigint(); try { return fn(); } finally { bump(stages, name, Number(process.hrtime.bigint() - a) / 1e6); sample(); } },
35
+ /** Time an async function as stage `name`. */
36
+ async timeAsync(name, fn) { const a = process.hrtime.bigint(); try { return await fn(); } finally { bump(stages, name, Number(process.hrtime.bigint() - a) / 1e6); sample(); } },
37
+ /** Record a duration measured elsewhere. */
38
+ record(name, ms) { bump(stages, name, Number.isFinite(ms) ? ms : 0); sample(); },
39
+ detector(name, ms) { bump(detectors, name, ms); },
40
+ count(name, n = 1) { own(() => counters.set(name, (counters.get(name) || 0) + n)); },
41
+ set(name, n) { own(() => counters.set(name, n)); },
42
+ sample,
43
+ /** Attribute a failure. `kind` is a short class (an error name or code); `message` is clipped and carries no source text. */
44
+ error(stage, kind, message) {
45
+ own(() => {
46
+ const key = `${stage}|${kind}`; const c = errors.get(key) || { stage, kind, count: 0, example: String(message ?? '').replace(/\s+/g, ' ').slice(0, 120) };
47
+ c.count += 1; errors.set(key, c);
48
+ });
49
+ },
50
+ snapshot() {
51
+ return own(() => {
52
+ sample();
53
+ const z = (v) => (deterministic ? 0 : Math.round(v * 100) / 100);
54
+ const byName = (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
55
+ const rows = (map) => [...map].sort(deterministic ? byName : (a, b) => b[1].ms - a[1].ms || byName(a, b)).map(([name, v]) => ({ name, ms: z(v.ms), calls: v.calls }));
56
+ return {
57
+ schema: 'agentic-security/scan-telemetry', version: 1, deterministic,
58
+ totalMs: z(Number(process.hrtime.bigint() - startedNs) / 1e6),
59
+ stages: rows(stages),
60
+ detectors: rows(detectors).slice(0, 25),
61
+ memory: { rssHighWaterMb: z(mem.rssHighWaterBytes / MB), heapHighWaterMb: z(mem.heapHighWaterBytes / MB), samples: mem.samples },
62
+ counters: Object.fromEntries([...counters].sort(byName)),
63
+ errors: [...errors.values()].sort((a, b) => (a.stage + a.kind < b.stage + b.kind ? -1 : 1)),
64
+ overhead: { selfMs: z(Number(selfNs) / 1e6) },
65
+ outbound: 'none: this record stays in the scan result',
66
+ };
67
+ });
68
+ },
69
+ };
70
+ return api;
71
+ }
72
+
73
+ /** Fold the existing error arrays of a scan into telemetry errors, by stage. Reads the arrays; changes nothing. */
74
+ export function attributeErrors(tel, { annotatorErrors = [], detectorErrors = [], deepFailure = null, filesTimedOut = 0 } = {}) {
75
+ for (const e of annotatorErrors) tel.error(`annotator:${e.phase || e.annotator || 'unknown'}`, e.name || 'Error', e.message || e.err);
76
+ for (const e of detectorErrors) tel.error(`detector:${e.analyzer || 'unknown'}`, 'DetectorError', e.err);
77
+ if (deepFailure) tel.error('deep-analysis', 'DeepFailure', deepFailure);
78
+ if (filesTimedOut > 0) tel.error('per-file-timeout', 'Timeout', `${filesTimedOut} file(s) exceeded the per-file budget`);
79
+ }
@@ -118,8 +118,9 @@ export function aggregateCorpus(detail) {
118
118
  * protocol is forced to `unmeasured`, and an absent report is `unmeasured` with
119
119
  * the reason, never an empty pass. Pure.
120
120
  */
121
- export function summarizeMeasurement(provenance, independent) {
121
+ export function summarizeMeasurement(provenance, independent, independentRun = null) {
122
122
  const p = provenance || {};
123
+ const run = summarizeIndependentRun(independentRun, p);
123
124
  const clean = p.worktreeClean === true;
124
125
  const hist = independent && independent.engineVersion && independent.engineVersion !== p.engineVersion;
125
126
  return {
@@ -128,14 +129,47 @@ export function summarizeMeasurement(provenance, independent) {
128
129
  identity: clean
129
130
  ? 'the commit and bundle below identify exactly the tree that was measured'
130
131
  : p.worktreeClean === false ? 'the worktree was dirty: the commit below does NOT identify what was measured, so this is not a clean measurement' : 'cleanliness of the measured tree was not recorded',
131
- currentEngineIndependentAccuracy: 'unmeasured',
132
- currentEngineIndependentReason: 'no adjudicated real-code population has been run against this engine; the independent record in this document, if any, describes an older engine',
132
+ currentEngineIndependentAccuracy: run && run.comparableToCurrentEngine ? (run.identity.measurementValid ? 'measured' : 'measured_not_release_grade') : 'unmeasured',
133
+ currentEngineIndependentReason: run && run.comparableToCurrentEngine
134
+ ? (run.identity.measurementValid ? `measured by run ${run.runId} on a clean commit; the figures are for the development and validation splits only` : `run ${run.runId} measured this engine but the measurement is not valid for a release claim: ${run.identity.invalidReasons.join(', ') || 'no identity recorded'}`)
135
+ : run ? `the latest independent run (${run.runId}) measured engine ${run.engineVersion}, not the engine above; it is a historical record` : 'no adjudicated real-code population has been run against this engine; the independent record in this document, if any, describes an older engine',
136
+ independentRun: run,
133
137
  historicalIndependentRecord: hist
134
138
  ? { engineVersion: independent.engineVersion, measuredAt: independent.measuredAt || null, usage: 'historical record only; not a measurement of the engine above and not combined with the corpus figures' }
135
139
  : null,
136
140
  };
137
141
  }
138
142
 
143
+ /**
144
+ * The latest clean-commit run of the independent population (bench/independent/runs/<id>/RESULT.json), reduced to what a reader may be shown.
145
+ * Only the localized definition is a claim. A language below the declared minimum is `insufficient_population` and shows counts, never a rate;
146
+ * a language in the manifest that was not scored at all is listed as unmeasured. Pure.
147
+ */
148
+ export function summarizeIndependentRun(run, provenance) {
149
+ if (!run || !run.scoring) return null;
150
+ const loc = run.scoring.definitions.localized;
151
+ const d = run.scoring.denominators;
152
+ const id = run.identity || {};
153
+ const rate = (r) => (r && r.d > 0 ? { n: r.n, d: r.d, value: r.value } : { n: r?.n ?? 0, d: r?.d ?? 0, value: null });
154
+ const byLanguage = Object.entries(loc.byLanguage).map(([language, g]) => (g.status === 'reportable'
155
+ ? { language, entries: g.entries, status: 'reportable', recall: rate(g.recall), precision: rate(g.precision), f1: g.f1, recallInterval: g.recallInterval, precisionInterval: g.precisionInterval, counts: { tp: g.tp, fp: g.fp, fn: g.fn, tn: g.tn } }
156
+ : { language, entries: g.entries, status: 'insufficient_population', counts: { tp: g.tp, fp: g.fp, fn: g.fn, tn: g.tn } }));
157
+ const unmeasuredLanguages = (run.population?.selection?.counts ? Object.keys(run.population.selection.counts) : []).filter((l) => !loc.byLanguage[l] || loc.byLanguage[l].status !== 'reportable');
158
+ return {
159
+ runId: run.runId, engineVersion: run.engineVersion || null, measuredAt: run.mergedAt || null,
160
+ comparableToCurrentEngine: !!run.engineVersion && run.engineVersion === (provenance || {}).engineVersion,
161
+ historical: !!run.engineVersion && run.engineVersion !== (provenance || {}).engineVersion,
162
+ identity: { sourceCommit: id.sourceCommit || null, bundleSha256: id.bundleSha256 || null, bundleSidecarSha256: id.bundleSidecarSha256 || null, measurementValid: id.measurementValid === true, invalidReasons: id.invalidReasons || [], lockfileSha256: id.lockfileSha256 || null, configSha256: id.configSha256 || null, modelEndpointMode: id.config?.modelEndpointMode || null, toolchain: id.toolchain ? { node: id.toolchain.node, os: id.toolchain.os } : null },
163
+ population: { role: run.population?.role || 'independent population', manifestSha256: run.population?.manifestSha256 || null, splitsProtocolHash: run.population?.splitsProtocolHash || run.splits?.protocolHash || null, splitNames: run.population?.selection?.splitNames || [], perLanguageCap: run.population?.selection?.perLanguage ?? null, sealedSplitTouched: run.population?.sealedSplitTouched === true },
164
+ configuration: run.configuration || null,
165
+ denominators: { inScope: d.inScope, scored: d.scored, unscored: d.unscored, unscoredByClass: d.unscoredByClass, unaccounted: d.unaccounted, reconciles: d.reconciles },
166
+ headline: run.headline ? { quotable: run.headline.quotable === true, reason: run.headline.reason || null, precision: run.headline.precision ? rate(run.headline.precision) : null, recall: run.headline.recall ? rate(run.headline.recall) : null, f1: run.headline.f1 ?? null, f1Interval: run.headline.f1Interval || null } : null,
167
+ byLanguage, unmeasuredLanguages,
168
+ minEntriesForRate: run.scoring.minEntriesForRate,
169
+ fixedVersionPressure: run.scoring.fixedVersionPressure || null,
170
+ };
171
+ }
172
+
139
173
  export function summarizeEvaluationGates(report) {
140
174
  if (!report || typeof report !== 'object') {
141
175
  return {
@@ -185,6 +219,17 @@ export function summarizeEvaluationGates(report) {
185
219
  * such scan renders "not measured this run" rather than a
186
220
  * fabricated rate. See PRD Success Metrics.
187
221
  */
222
+ /**
223
+ * What happened to one third-party target: `completed`, `not_attempted` (it never ran: no pinned commit, so there was nothing to clone) or `failed`
224
+ * (it ran and did not finish). A pre-existing summary records an unpinned target as `error`; the message is the evidence, so it is read from there.
225
+ */
226
+ export function proofTargetOutcome(t) {
227
+ if (t && t.status === 'ok') return 'completed';
228
+ if (t && t.status === 'not_attempted') return 'not_attempted';
229
+ if (t && t.status === 'error' && /has no pinned commit/.test(String(t.error || ''))) return 'not_attempted';
230
+ return 'failed';
231
+ }
232
+
188
233
  export function buildScorecard(inputs) {
189
234
  const corpus = aggregateCorpus(inputs.corpusDetail);
190
235
  const selfScan = inputs.selfScan || {};
@@ -324,9 +369,11 @@ export function buildScorecard(inputs) {
324
369
  bundleSha: proof.bundleSha || null,
325
370
  targetCount: proof.targetCount ?? null,
326
371
  ok: proof.ok ?? null,
327
- failed: proof.failed ?? null,
372
+ // `failed` in the committed summary counted targets that never ran; the three outcomes are separated here
373
+ failed: (proof.targets || []).filter(t => proofTargetOutcome(t) === 'failed').length,
374
+ notAttempted: (proof.targets || []).filter(t => proofTargetOutcome(t) === 'not_attempted').length,
328
375
  targets: (proof.targets || []).map(t => ({
329
- id: t.id, commit: t.commit, status: t.status,
376
+ id: t.id, commit: t.commit, status: t.status, outcome: proofTargetOutcome(t),
330
377
  filesInScope: t.coverage?.totals?.inScope ?? null,
331
378
  filesParsed: t.coverage?.totals?.parsed ?? null,
332
379
  determinismChecked: t.determinism?.checked ?? null,
@@ -349,7 +396,7 @@ export function buildScorecard(inputs) {
349
396
  // QA-001/QA-003: the real-code gates registered in the evaluation protocol. Relayed, never computed here.
350
397
  evaluationGates: summarizeEvaluationGates(inputs.evaluation),
351
398
  // QA-008.AC01: what this scorecard measured, stated once. `clean` is what lets the commit and bundle identify the measured tree.
352
- measurement: summarizeMeasurement(inputs.provenance, committed.independent),
399
+ measurement: summarizeMeasurement(inputs.provenance, committed.independent, committed.independentRun),
353
400
  };
354
401
  }
355
402
 
@@ -403,7 +450,10 @@ export function renderScorecardMarkdown(m) {
403
450
  }
404
451
  if (m.measurement) {
405
452
  L.push(`| Measurement identity | ${m.measurement.cleanMeasurement ? 'clean measurement: the commit and bundle above identify the measured tree' : 'NOT a clean measurement: the commit and bundle above do not identify the measured tree'} |`);
406
- L.push('| Independent accuracy of THIS engine | unmeasured (no adjudicated real-code population has been run; see the historical record below, if present) |');
453
+ const ia = m.measurement.currentEngineIndependentAccuracy;
454
+ L.push(ia === 'unmeasured'
455
+ ? '| Independent accuracy of THIS engine | unmeasured (no real-code run of this engine exists; see the historical record below, if present) |'
456
+ : `| Independent accuracy of THIS engine | ${ia === 'measured' ? 'measured on a clean commit' : 'measured, but NOT release grade'}: ${m.measurement.currentEngineIndependentReason} |`);
407
457
  }
408
458
  L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
409
459
  L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
@@ -733,6 +783,45 @@ export function renderScorecardMarkdown(m) {
733
783
  L.push('');
734
784
  }
735
785
 
786
+ const run = m.measurement && m.measurement.independentRun;
787
+ if (run) {
788
+ const pc = (r) => (r && r.value !== null && r.value !== undefined ? `${(r.value * 100).toFixed(1)}% (${r.n}/${r.d})` : `n/a (${r ? r.n : 0}/${r ? r.d : 0})`);
789
+ const iv = (i) => (i && i.status === 'measured' ? `${(i.low * 100).toFixed(1)}% to ${(i.high * 100).toFixed(1)}%` : 'unmeasured');
790
+ L.push(run.comparableToCurrentEngine ? `## Independent accuracy of this engine (run ${run.runId})` : `## Independent run ${run.runId}: historical record (engine ${run.engineVersion})`);
791
+ L.push('');
792
+ L.push('This is the independent population, upstream code labelled by public advisories, run on the engine below at a named commit. It is a different');
793
+ L.push('instrument from the curated regression net above and the two figures are never combined. Only the **localized** definition (a matching finding landed on code the');
794
+ L.push('fix changed) is a claim; it is reported with numerator and denominator.');
795
+ L.push('');
796
+ L.push('| Field | Value |');
797
+ L.push('| --- | --- |');
798
+ L.push(`| Engine | ${run.engineVersion}${run.comparableToCurrentEngine ? '' : ' (NOT the engine this scorecard describes)'} |`);
799
+ L.push(`| Measured at commit | \`${run.identity.sourceCommit || 'unknown'}\` |`);
800
+ L.push(`| Bundle SHA-256 | \`${run.identity.bundleSha256 || 'unknown'}\` (sidecar ${run.identity.bundleSidecarSha256 && run.identity.bundleSidecarSha256 === run.identity.bundleSha256 ? 'matches' : 'does not match or is missing'}) |`);
801
+ L.push(`| Measurement | ${run.identity.measurementValid ? 'valid: clean tree, bundle matches its digest' : `NOT valid for a release claim: ${run.identity.invalidReasons.join(', ') || 'no identity recorded'}`} |`);
802
+ L.push(`| Configuration | ${run.configuration ? `deep=${run.configuration.deep}, model=${run.configuration.llm}` : 'unknown'}; model endpoint ${run.identity.modelEndpointMode || 'unknown'} |`);
803
+ L.push(`| Population | manifest \`${(run.population.manifestSha256 || 'unknown').slice(0, 16)}\`; splits protocol \`${(run.population.splitsProtocolHash || 'unknown').slice(0, 23)}\`; ${run.population.splitNames.join(' + ') || 'unknown'} only, at most ${run.population.perLanguageCap ?? 'all'} entries per language; sealed split touched: ${run.population.sealedSplitTouched ? 'YES' : 'no'} |`);
804
+ L.push(`| Date (UTC) | ${run.measuredAt || 'unknown'} |`);
805
+ L.push(`| Entries in scope | ${run.denominators.inScope}: scored ${run.denominators.scored}, unscored ${run.denominators.unscored}${Object.keys(run.denominators.unscoredByClass || {}).length ? ` (${Object.entries(run.denominators.unscoredByClass).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}, unaccounted ${run.denominators.unaccounted}; denominators ${run.denominators.reconciles ? 'reconcile' : 'DO NOT reconcile'} |`);
806
+ L.push('');
807
+ if (run.headline && run.headline.quotable) {
808
+ L.push(`**Headline (localized):** precision ${pc(run.headline.precision)}, recall ${pc(run.headline.recall)}, F1 ${run.headline.f1 === null ? 'n/a' : run.headline.f1.toFixed(3)}${run.headline.f1Interval && run.headline.f1Interval.status === 'measured' ? ` (95% interval ${iv(run.headline.f1Interval)}, resampling whole repositories)` : ' (interval unmeasured)'}.`);
809
+ } else {
810
+ L.push(`**No headline figure:** ${run.headline && run.headline.reason ? run.headline.reason : 'the run is not quotable'}.`);
811
+ }
812
+ L.push('');
813
+ L.push(`| Language | Scored | Recall | Precision | Recall 95% interval | Status |`);
814
+ L.push('| --- | --- | --- | --- | --- | --- |');
815
+ for (const g of run.byLanguage) {
816
+ if (g.status === 'reportable') L.push(`| ${g.language} | ${g.entries} | ${pc(g.recall)} | ${pc(g.precision)} | ${iv(g.recallInterval)} | reportable |`);
817
+ else L.push(`| ${g.language} | ${g.entries} | counts only (${g.counts.tp} true positive, ${g.counts.fn} missed) | | | insufficient_population (below ${run.minEntriesForRate}) |`);
818
+ }
819
+ L.push('');
820
+ if (run.unmeasuredLanguages.length) L.push(`Languages with no rate in this run: ${run.unmeasuredLanguages.join(', ')}. They are unmeasured here, not good or bad.`, '');
821
+ L.push('This run does not use the sealed split, so it is a baseline on development and validation data, not a held-out result, and it makes no claim about the 80% per-language target.');
822
+ L.push('');
823
+ }
824
+
736
825
  const ind = m.committedInputs.independent;
737
826
  if (ind && ind.overall) {
738
827
  const hist = ind.historical === true;
@@ -874,7 +963,7 @@ export function renderScorecardMarkdown(m) {
874
963
  const pc = m.committedInputs.proofCorpus;
875
964
  if (pc) {
876
965
  L.push(`- **Third-party repository run** (*committed artifact*, \`${pc.source}\`,`);
877
- L.push(` bundle \`${pc.bundleSha}\`): ${pc.ok}/${pc.targetCount} targets completed.`);
966
+ L.push(` bundle \`${pc.bundleSha}\`): ${pc.ok} of ${pc.targetCount} targets completed, ${pc.notAttempted ?? 0} not attempted (no pinned commit, so nothing was cloned), ${pc.failed ?? 0} failed after starting.`);
878
967
  L.push(' Reported for scale and parse coverage only — these repositories have no');
879
968
  L.push(' vulnerability ground truth, so no accuracy rate is derived from them.');
880
969
  L.push('');
@@ -882,7 +971,7 @@ export function renderScorecardMarkdown(m) {
882
971
  L.push('| --- | --- | --- | --- | --- | --- |');
883
972
  for (const t of pc.targets) {
884
973
  const det = t.determinismChecked ? (t.determinismIdentical ? 'identical' : 'checked, not byte-identical') : 'not checked';
885
- L.push(`| ${t.id} | \`${String(t.commit || '').slice(0, 12)}\` | ${t.status} | ${formatRate(t.filesParsed ?? 0, t.filesInScope ?? 0)} | ${t.resultsEmitted ?? 'n/a'} | ${det} |`);
974
+ L.push(`| ${t.id} | \`${String(t.commit || '').slice(0, 12)}\` | ${t.outcome === 'not_attempted' ? 'not attempted' : t.status} | ${formatRate(t.filesParsed ?? 0, t.filesInScope ?? 0)} | ${t.resultsEmitted ?? 'n/a'} | ${det} |`);
886
975
  }
887
976
  }
888
977
  L.push('');
@@ -144,6 +144,7 @@ export const ARTIFACT_REGISTRY = [
144
144
  { name: 'findings.csv', kind: 'file', classification: 'generated', retentionClass: 'scan' },
145
145
  { name: 'llm-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache' },
146
146
  { name: 'fix-history', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
147
+ { name: 'fix-batches', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
147
148
  { name: 'verifier-runs', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'src/posture/verifier.js (recordVerifierRun)', note: 'Adversarial premortem Q1 (2026-09-07): one JSON record per `agentic-security verify` run, added so module:verifier (nist-800-171-r3.json 03.12.01, nist-csf-2.json RC.RP) has a real artifact to check for instead of a name in the ARTIFACT table nothing ever wrote.' },
148
149
  { name: 'fix-plans', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
149
150
  // The following were confirmed missing from the old hardcoded WIPE/
@@ -172,6 +173,8 @@ export const ARTIFACT_REGISTRY = [
172
173
  { name: 'compliance', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/auditor-walkthrough.js' },
173
174
  { name: 'attestations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/evidence-bundle.js' },
174
175
  { name: 'auditor-walkthroughs', kind: 'dir', classification: 'generated', retentionClass: 'evidence' },
176
+ { name: 'compliance-applicability.json', kind: 'file', classification: 'operator-config', note: 'operator declarations that a framework control does not apply ({ <framework id>: { <control id>: { reason, declaredBy } } }); read by `compliance --report <framework> --truth`. The tool never infers non-applicability.' },
177
+ { name: 'analysis-cache.jsonl', kind: 'file', classification: 'generated', retentionClass: 'cache', confidential: true, source: 'posture/scan-checkpoint.js (openCheckpoint persistent mode), engine.js', note: 'Opt-in (AGENTIC_SECURITY_ANALYSIS_CACHE=1) cross-scan cache of per-file detector results. Each record is authenticated with the install key, so a cache carried in from elsewhere is discarded. It holds findings with source excerpts, so it is treated like the source tree.' },
175
178
  { name: 'incremental', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'dataflow/incremental.js (cache)' },
176
179
  { name: 'model-rescan', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
177
180
  { name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
@@ -279,6 +282,8 @@ export const ARTIFACT_REGISTRY = [
279
282
  // retention cannot reach it. Operator-config, not generated: these are
280
283
  // hand-supplied eBPF/APM trace exports the scanner only ever reads.
281
284
  { name: 'runtime-trace.jsonl', kind: 'file', classification: 'operator-config', note: 'eBPF/APM runtime trace consumed by posture/runtime-correlation.js\'s own trace loader (deliberately not named by its literal export here — no-dead-modules.test.js scans note strings too, and naming it would make it look, wrongly, like a real call site) — operator-supplied, never scanner-written; $AGENTIC_SECURITY_RUNTIME_TRACE_PATH overrides the location entirely' },
285
+ { name: 'runtime-policy.json', kind: 'file', classification: 'operator-config', note: 'opt-in policy for runtime observation ingestion (collect, kinds, retentionDays, sampleRate); read by posture/runtime-trace-ingest.js' },
286
+ { name: 'runtime-trace.meta.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: 'ingestion record beside runtime-trace.jsonl: declared sampling, retention, counts of dropped and rejected fields (counts only, never values)' },
282
287
  { name: 'runtime.jsonl', kind: 'file', classification: 'operator-config', note: 'alternate filename for runtime-trace.jsonl — see posture/runtime-correlation.js\'s DEFAULT_TRACE_NAMES' },
283
288
  { name: 'ebpf-trace.jsonl', kind: 'file', classification: 'operator-config', note: 'alternate filename for runtime-trace.jsonl — see posture/runtime-correlation.js\'s DEFAULT_TRACE_NAMES' },
284
289
  ];