@clear-capabilities/agentic-security-scanner 0.74.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 (331) hide show
  1. package/CHANGELOG.md +1580 -0
  2. package/bin/.agentic-security/findings.json +1577 -0
  3. package/bin/.agentic-security/last-scan.json +1577 -0
  4. package/bin/.agentic-security/last-scan.json.sig +1 -0
  5. package/bin/.agentic-security/scan-history.json +465 -0
  6. package/bin/.agentic-security/streak.json +25 -0
  7. package/bin/agentic-security-audit.js +198 -0
  8. package/bin/agentic-security-consistency.js +80 -0
  9. package/bin/agentic-security-diff.js +136 -0
  10. package/bin/agentic-security-lsp.js +12 -0
  11. package/bin/agentic-security-mcp.js +40 -0
  12. package/bin/agentic-security-rule.js +153 -0
  13. package/bin/agentic-security.js +1683 -0
  14. package/dist/117.index.js +207 -0
  15. package/dist/178.index.js +250 -0
  16. package/dist/218.index.js +793 -0
  17. package/dist/227.index.js +192 -0
  18. package/dist/301.index.js +167 -0
  19. package/dist/384.index.js +18 -0
  20. package/dist/476.index.js +126 -0
  21. package/dist/513.index.js +373 -0
  22. package/dist/520.index.js +13 -0
  23. package/dist/601.index.js +1038 -0
  24. package/dist/634.index.js +1892 -0
  25. package/dist/637.index.js +216 -0
  26. package/dist/660.index.js +131 -0
  27. package/dist/675.index.js +451 -0
  28. package/dist/826.index.js +188 -0
  29. package/dist/830.index.js +133 -0
  30. package/dist/agentic-security.mjs +272 -0
  31. package/dist/agentic-security.mjs.sha256 +1 -0
  32. package/dist/calibration-seed.json +27 -0
  33. package/package.json +77 -0
  34. package/src/.agentic-security/findings.json +80844 -0
  35. package/src/.agentic-security/last-scan.json +80844 -0
  36. package/src/.agentic-security/last-scan.json.sig +1 -0
  37. package/src/.agentic-security/scan-history.json +8408 -0
  38. package/src/.agentic-security/streak.json +26 -0
  39. package/src/badge.js +188 -0
  40. package/src/compare.js +203 -0
  41. package/src/dataflow/.agentic-security/findings.json +3487 -0
  42. package/src/dataflow/.agentic-security/last-scan.json +3487 -0
  43. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  44. package/src/dataflow/.agentic-security/scan-history.json +735 -0
  45. package/src/dataflow/.agentic-security/streak.json +24 -0
  46. package/src/dataflow/CLAUDE.md +38 -0
  47. package/src/dataflow/access-paths.js +172 -0
  48. package/src/dataflow/async-sequencing.js +177 -0
  49. package/src/dataflow/backward.js +201 -0
  50. package/src/dataflow/catalog-expanded.js +485 -0
  51. package/src/dataflow/catalog.js +659 -0
  52. package/src/dataflow/cross-repo.js +219 -0
  53. package/src/dataflow/engine.js +588 -0
  54. package/src/dataflow/exception-flow.js +116 -0
  55. package/src/dataflow/exploit-prover.js +187 -0
  56. package/src/dataflow/higher-order.js +221 -0
  57. package/src/dataflow/ifds.js +347 -0
  58. package/src/dataflow/implicit-flow.js +129 -0
  59. package/src/dataflow/incremental.js +229 -0
  60. package/src/dataflow/index.js +181 -0
  61. package/src/dataflow/numeric-domain.js +192 -0
  62. package/src/dataflow/path-feasibility.js +114 -0
  63. package/src/dataflow/points-to.js +337 -0
  64. package/src/dataflow/polyglot.js +190 -0
  65. package/src/dataflow/proven-clean.js +159 -0
  66. package/src/dataflow/receiver-context.js +76 -0
  67. package/src/dataflow/sanitizer-proof.js +154 -0
  68. package/src/dataflow/soft-taint.js +140 -0
  69. package/src/dataflow/string-domain.js +234 -0
  70. package/src/dataflow/stub-aware-filter.js +100 -0
  71. package/src/dataflow/summaries.js +132 -0
  72. package/src/dataflow/symbolic-exec.js +238 -0
  73. package/src/dataflow/tabulation.js +135 -0
  74. package/src/engine.js +7763 -0
  75. package/src/history-scan.js +229 -0
  76. package/src/index.js +3 -0
  77. package/src/integrations/.agentic-security/findings.json +1504 -0
  78. package/src/integrations/.agentic-security/last-scan.json +1504 -0
  79. package/src/integrations/.agentic-security/scan-history.json +40 -0
  80. package/src/integrations/.agentic-security/streak.json +21 -0
  81. package/src/integrations/index.js +321 -0
  82. package/src/integrations/tickets.js +200 -0
  83. package/src/ir/.agentic-security/findings.json +3036 -0
  84. package/src/ir/.agentic-security/last-scan.json +3036 -0
  85. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  86. package/src/ir/.agentic-security/scan-history.json +364 -0
  87. package/src/ir/.agentic-security/streak.json +23 -0
  88. package/src/ir/CLAUDE.md +172 -0
  89. package/src/ir/callgraph.js +73 -0
  90. package/src/ir/class-hierarchy.js +195 -0
  91. package/src/ir/index.js +152 -0
  92. package/src/ir/parser-cs.js +260 -0
  93. package/src/ir/parser-java.js +286 -0
  94. package/src/ir/parser-js.js +413 -0
  95. package/src/ir/parser-kt.js +258 -0
  96. package/src/ir/parser-py-cst.js +136 -0
  97. package/src/ir/parser-py.helper.py +501 -0
  98. package/src/ir/parser-py.js +312 -0
  99. package/src/ir/ssa.js +315 -0
  100. package/src/ir/type-stubs.js +288 -0
  101. package/src/leaderboard.js +152 -0
  102. package/src/llm-validator/.agentic-security/findings.json +1891 -0
  103. package/src/llm-validator/.agentic-security/last-scan.json +1891 -0
  104. package/src/llm-validator/.agentic-security/last-scan.json.sig +1 -0
  105. package/src/llm-validator/.agentic-security/scan-history.json +168 -0
  106. package/src/llm-validator/.agentic-security/streak.json +20 -0
  107. package/src/llm-validator/consistency.js +141 -0
  108. package/src/llm-validator/index.js +437 -0
  109. package/src/lsp/.agentic-security/findings.json +28 -0
  110. package/src/lsp/.agentic-security/last-scan.json +28 -0
  111. package/src/lsp/.agentic-security/scan-history.json +79 -0
  112. package/src/lsp/.agentic-security/streak.json +22 -0
  113. package/src/lsp/server.js +275 -0
  114. package/src/mcp/.agentic-security/findings.json +8358 -0
  115. package/src/mcp/.agentic-security/last-scan.json +8358 -0
  116. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  117. package/src/mcp/.agentic-security/scan-history.json +1125 -0
  118. package/src/mcp/.agentic-security/streak.json +22 -0
  119. package/src/mcp/CLAUDE.md +54 -0
  120. package/src/mcp/audit.js +136 -0
  121. package/src/mcp/redact.js +75 -0
  122. package/src/mcp/server.js +158 -0
  123. package/src/mcp/stdio.js +83 -0
  124. package/src/mcp/tools.js +940 -0
  125. package/src/mcp/validate.js +49 -0
  126. package/src/personality.js +164 -0
  127. package/src/poc-video.js +239 -0
  128. package/src/posture/.agentic-security/findings.json +51239 -0
  129. package/src/posture/.agentic-security/last-scan.json +51239 -0
  130. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  131. package/src/posture/.agentic-security/scan-history.json +5557 -0
  132. package/src/posture/.agentic-security/streak.json +24 -0
  133. package/src/posture/CLAUDE.md +42 -0
  134. package/src/posture/adversarial-self-test.js +114 -0
  135. package/src/posture/adversary-agent.js +204 -0
  136. package/src/posture/agents-memory.js +135 -0
  137. package/src/posture/ai-code-fingerprint.js +171 -0
  138. package/src/posture/aibom.js +284 -0
  139. package/src/posture/api-inventory.js +96 -0
  140. package/src/posture/attack-playbooks.js +305 -0
  141. package/src/posture/auditor-agent.js +115 -0
  142. package/src/posture/auth-posture-import.js +135 -0
  143. package/src/posture/baseline-compare.js +114 -0
  144. package/src/posture/blast-radius.js +836 -0
  145. package/src/posture/bounty-prediction.js +141 -0
  146. package/src/posture/business-logic.js +239 -0
  147. package/src/posture/calibration-drift.js +93 -0
  148. package/src/posture/calibration-seed.json +27 -0
  149. package/src/posture/calibration.js +204 -0
  150. package/src/posture/clustering.js +75 -0
  151. package/src/posture/concurrency-checker.js +265 -0
  152. package/src/posture/confidence.js +65 -0
  153. package/src/posture/container-runtime.js +149 -0
  154. package/src/posture/counterfactual.js +109 -0
  155. package/src/posture/cross-lang-graphql.js +165 -0
  156. package/src/posture/cross-lang-grpc.js +166 -0
  157. package/src/posture/cross-lang-meta.js +101 -0
  158. package/src/posture/cross-lang-openapi.js +187 -0
  159. package/src/posture/cross-lang-orm.js +153 -0
  160. package/src/posture/cross-lang-queues.js +210 -0
  161. package/src/posture/crown-jewels.js +110 -0
  162. package/src/posture/custom-rules.js +361 -0
  163. package/src/posture/cve-alert-daemon.js +433 -0
  164. package/src/posture/cve-lookup.js +129 -0
  165. package/src/posture/dead-code.js +430 -0
  166. package/src/posture/defender-agent.js +158 -0
  167. package/src/posture/deploy-platform.js +204 -0
  168. package/src/posture/detector-fuzz.js +61 -0
  169. package/src/posture/deterministic.js +99 -0
  170. package/src/posture/drift.js +165 -0
  171. package/src/posture/epss.js +156 -0
  172. package/src/posture/exploitability-probability.js +212 -0
  173. package/src/posture/exploitability.js +121 -0
  174. package/src/posture/feature-flags.js +110 -0
  175. package/src/posture/finding-defaults.js +132 -0
  176. package/src/posture/fix-history.js +411 -0
  177. package/src/posture/fix-plan.js +121 -0
  178. package/src/posture/fix-verify-loop.js +157 -0
  179. package/src/posture/fix-verify.js +130 -0
  180. package/src/posture/flow-narration.js +105 -0
  181. package/src/posture/grader-calibration.js +156 -0
  182. package/src/posture/harness-discovery.js +113 -0
  183. package/src/posture/holdout-eval.js +144 -0
  184. package/src/posture/iac-reachability.js +163 -0
  185. package/src/posture/iam-policy.js +128 -0
  186. package/src/posture/integrity.js +97 -0
  187. package/src/posture/learning.js +166 -0
  188. package/src/posture/license-policy.js +109 -0
  189. package/src/posture/llm-redteam-prompts.js +418 -0
  190. package/src/posture/llm-redteam.js +303 -0
  191. package/src/posture/material-change.js +163 -0
  192. package/src/posture/mitigation-composite.js +55 -0
  193. package/src/posture/mttr.js +91 -0
  194. package/src/posture/network-policy-import.js +126 -0
  195. package/src/posture/path-predicates.js +99 -0
  196. package/src/posture/persona-prioritization.js +153 -0
  197. package/src/posture/poc-cwe-map.js +51 -0
  198. package/src/posture/poc-generator.js +500 -0
  199. package/src/posture/policy-gate.js +174 -0
  200. package/src/posture/pre-incident-archaeology.js +110 -0
  201. package/src/posture/profile.js +93 -0
  202. package/src/posture/reachability-filter.js +42 -0
  203. package/src/posture/regression-test-gen.js +200 -0
  204. package/src/posture/reverse-blast-radius.js +110 -0
  205. package/src/posture/router.js +109 -0
  206. package/src/posture/rule-overrides.js +198 -0
  207. package/src/posture/rule-pack-signing.js +209 -0
  208. package/src/posture/rule-packs.js +143 -0
  209. package/src/posture/rule-synthesis.js +108 -0
  210. package/src/posture/ruleset-version.js +71 -0
  211. package/src/posture/sbom.js +129 -0
  212. package/src/posture/schema-aware-bridge.js +207 -0
  213. package/src/posture/security-trend.js +87 -0
  214. package/src/posture/semantic-clone.js +114 -0
  215. package/src/posture/specification-mining.js +170 -0
  216. package/src/posture/stable-id.js +75 -0
  217. package/src/posture/stack-playbook.js +229 -0
  218. package/src/posture/streak.js +249 -0
  219. package/src/posture/suppressions.js +135 -0
  220. package/src/posture/telemetry-ingest.js +112 -0
  221. package/src/posture/threat-model.js +145 -0
  222. package/src/posture/three-agent-pipeline.js +74 -0
  223. package/src/posture/triage.js +146 -0
  224. package/src/posture/trust-boundary-diagram.js +115 -0
  225. package/src/posture/type-narrowing.js +129 -0
  226. package/src/posture/validator-metrics.js +179 -0
  227. package/src/posture/verifier-ephemeral.js +118 -0
  228. package/src/posture/verifier-target.js +147 -0
  229. package/src/posture/verifier.js +257 -0
  230. package/src/posture/version.js +75 -0
  231. package/src/posture/waf-ingest.js +200 -0
  232. package/src/posture/why-fired.js +141 -0
  233. package/src/pr-comment.js +172 -0
  234. package/src/pr-delta.js +198 -0
  235. package/src/report/.agentic-security/findings.json +79 -0
  236. package/src/report/.agentic-security/last-scan.json +79 -0
  237. package/src/report/.agentic-security/last-scan.json.sig +1 -0
  238. package/src/report/.agentic-security/scan-history.json +332 -0
  239. package/src/report/.agentic-security/streak.json +23 -0
  240. package/src/report/index.js +1136 -0
  241. package/src/report/mascot.js +42 -0
  242. package/src/runScan.js +141 -0
  243. package/src/sast/.agentic-security/findings.json +5051 -0
  244. package/src/sast/.agentic-security/last-scan.json +5051 -0
  245. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  246. package/src/sast/.agentic-security/scan-history.json +788 -0
  247. package/src/sast/.agentic-security/streak.json +23 -0
  248. package/src/sast/CLAUDE.md +39 -0
  249. package/src/sast/_comment-strip.js +46 -0
  250. package/src/sast/agent-tool-escalation.js +131 -0
  251. package/src/sast/auth-provider.js +171 -0
  252. package/src/sast/authz.js +236 -0
  253. package/src/sast/bench-shape/.agentic-security/findings.json +28 -0
  254. package/src/sast/bench-shape/.agentic-security/last-scan.json +28 -0
  255. package/src/sast/bench-shape/.agentic-security/scan-history.json +24 -0
  256. package/src/sast/bench-shape/.agentic-security/streak.json +22 -0
  257. package/src/sast/bench-shape/index.js +62 -0
  258. package/src/sast/claude-hook-injection.js +199 -0
  259. package/src/sast/claude-md-prompt-injection.js +170 -0
  260. package/src/sast/claude-settings.js +165 -0
  261. package/src/sast/client-side.js +149 -0
  262. package/src/sast/cpp-bench-extras.js +122 -0
  263. package/src/sast/cpp-dataflow.js +430 -0
  264. package/src/sast/cpp.js +248 -0
  265. package/src/sast/csharp.js +152 -0
  266. package/src/sast/csrf.js +82 -0
  267. package/src/sast/dart-flutter.js +173 -0
  268. package/src/sast/db-rls.js +147 -0
  269. package/src/sast/db-taint.js +215 -0
  270. package/src/sast/defi-deep.js +242 -0
  271. package/src/sast/deserialization-gadgets.js +113 -0
  272. package/src/sast/django-hardening.js +230 -0
  273. package/src/sast/env-hygiene.js +125 -0
  274. package/src/sast/fastapi-hardening.js +145 -0
  275. package/src/sast/go-extended.js +84 -0
  276. package/src/sast/host-header.js +106 -0
  277. package/src/sast/index.js +17 -0
  278. package/src/sast/java-ast-folding.js +561 -0
  279. package/src/sast/java-bench-extras.js +708 -0
  280. package/src/sast/java-collection-passthrough.js +178 -0
  281. package/src/sast/java-constant-fold.js +244 -0
  282. package/src/sast/java-deserialization.js +125 -0
  283. package/src/sast/jndi.js +104 -0
  284. package/src/sast/juliet-shape.js +324 -0
  285. package/src/sast/jwt-exp.js +104 -0
  286. package/src/sast/kotlin.js +82 -0
  287. package/src/sast/laravel-hardening.js +198 -0
  288. package/src/sast/ldap-injection.js +100 -0
  289. package/src/sast/llm-owasp.js +465 -0
  290. package/src/sast/llm-stored-prompt.js +103 -0
  291. package/src/sast/llm-trading-agent.js +161 -0
  292. package/src/sast/llm.js +308 -0
  293. package/src/sast/logic.js +140 -0
  294. package/src/sast/mass-assignment.js +101 -0
  295. package/src/sast/mcp-audit.js +242 -0
  296. package/src/sast/mobile-manifest.js +195 -0
  297. package/src/sast/model-load.js +164 -0
  298. package/src/sast/mutation-xss.js +87 -0
  299. package/src/sast/nosql-injection.js +82 -0
  300. package/src/sast/open-redirect.js +119 -0
  301. package/src/sast/php.js +91 -0
  302. package/src/sast/pipeline.js +122 -0
  303. package/src/sast/primary-cwe-java.js +155 -0
  304. package/src/sast/prompt-firewall.js +151 -0
  305. package/src/sast/prompt-template.js +157 -0
  306. package/src/sast/prototype-pollution.js +112 -0
  307. package/src/sast/python-sinks.js +195 -0
  308. package/src/sast/quarkus-hardening.js +102 -0
  309. package/src/sast/rag-poisoning.js +118 -0
  310. package/src/sast/rate-limit.js +128 -0
  311. package/src/sast/response-splitting.js +138 -0
  312. package/src/sast/ruby.js +108 -0
  313. package/src/sast/rust.js +105 -0
  314. package/src/sast/solidity.js +167 -0
  315. package/src/sast/springboot-hardening.js +186 -0
  316. package/src/sast/ssrf-cloud-metadata.js +80 -0
  317. package/src/sast/ssti.js +116 -0
  318. package/src/sast/swift.js +162 -0
  319. package/src/sast/toctou.js +95 -0
  320. package/src/sast/webhook.js +101 -0
  321. package/src/sast/xpath-injection.js +51 -0
  322. package/src/sast/xxe.js +140 -0
  323. package/src/sast/zip-slip.js +200 -0
  324. package/src/sca/base-images.json +45 -0
  325. package/src/sca/container.js +107 -0
  326. package/src/sca/dep-confusion.js +134 -0
  327. package/src/sca/index.js +6 -0
  328. package/src/sca/popular-packages.json +41 -0
  329. package/src/sca/sarif-ingest.js +187 -0
  330. package/src/sca/vuln-function-hints.json +89 -0
  331. package/src/secrets/index.js +4 -0
@@ -0,0 +1,204 @@
1
+ // Brier-calibrated confidence (P1.3 / FR-UX-1, FR-UX-2).
2
+ //
3
+ // Today's `confidence` field is an ordinal score: combinations of severity,
4
+ // parser type, route-rooting, and a few heuristic adjustments. It correlates
5
+ // with true-positive rate but isn't calibrated — a "0.8" today doesn't mean
6
+ // "80% likely TP," it means "above-the-fold finding."
7
+ //
8
+ // This module turns the ordinal score into a calibrated probability via a
9
+ // per-family bucket map of historical TP rates from `validator-metrics.json`.
10
+ // It also computes:
11
+ //
12
+ // - 95% Wilson-score confidence intervals (small-sample-safe; never reports
13
+ // a CI of [0.95, 1.00] from a single observation).
14
+ // - The running Brier score on the labeled history, so the operator can
15
+ // see how well the calibration tracks reality.
16
+ //
17
+ // HONESTY: when a family has fewer than `MIN_SAMPLES_FOR_CALIBRATION` labels
18
+ // (default 30), we refuse to ship a calibrated number and instead emit
19
+ // `null` with a reason. Pillar-6 of the parent PRD calls this out: "When the
20
+ // verifier cannot rule a finding in or out, surface 'cannot verify' rather
21
+ // than pick a confidence number out of a hat."
22
+ //
23
+ // Seed corpus: the v1 calibration table is seeded from per-family TP/FP
24
+ // counts collected by the bench-realworld runner against OWASP Benchmark
25
+ // v1.2 and the curated Juliet subsets. Customers' own `validator-metrics.json`
26
+ // extends and overrides per-family.
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+
31
+ const MIN_SAMPLES_FOR_CALIBRATION = 30;
32
+
33
+ // ─── Wilson-score interval ──────────────────────────────────────────────────
34
+ //
35
+ // Returns [lower, upper] for proportion p with n observations at 95% conf.
36
+ // Source: https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval
37
+
38
+ const Z_95 = 1.959963984540054;
39
+
40
+ export function wilsonInterval(tp, n) {
41
+ if (n <= 0) return [0, 1];
42
+ const p = tp / n;
43
+ const z = Z_95;
44
+ const denom = 1 + (z * z) / n;
45
+ const centre = p + (z * z) / (2 * n);
46
+ const margin = z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n));
47
+ const lower = Math.max(0, (centre - margin) / denom);
48
+ const upper = Math.min(1, (centre + margin) / denom);
49
+ return [lower, upper];
50
+ }
51
+
52
+ // ─── Brier score ─────────────────────────────────────────────────────────────
53
+ //
54
+ // brier = mean( (prediction - actual)^2 )
55
+ // 0 = perfect; 0.25 = worse than coin flip; 1 = always wrong.
56
+ // PRD success criterion: ≤ 0.10.
57
+
58
+ export function brierScore(samples) {
59
+ if (!Array.isArray(samples) || samples.length === 0) return null;
60
+ let sum = 0, n = 0;
61
+ for (const s of samples) {
62
+ if (!s || typeof s.prediction !== 'number' || typeof s.actual !== 'number') continue;
63
+ const p = Math.max(0, Math.min(1, s.prediction));
64
+ const a = Math.max(0, Math.min(1, s.actual));
65
+ sum += (p - a) * (p - a);
66
+ n++;
67
+ }
68
+ return n > 0 ? sum / n : null;
69
+ }
70
+
71
+ // ─── Per-family calibration table ────────────────────────────────────────────
72
+ //
73
+ // Map<family, { tp, fp, n, calibrated, ci95 }>
74
+ // tp — labeled true positives in this family
75
+ // fp — labeled false positives in this family
76
+ // n — tp + fp
77
+ // calibrated — tp / n (only set when n >= MIN_SAMPLES_FOR_CALIBRATION)
78
+ // ci95 — [lower, upper]
79
+
80
+ export function buildCalibrationTable(history) {
81
+ if (!history || typeof history !== 'object') return {};
82
+ const out = {};
83
+ const families = history.families || history.perFamily || {};
84
+ for (const [fam, raw] of Object.entries(families)) {
85
+ if (!raw || typeof raw !== 'object') continue;
86
+ const tp = Number(raw.tp) || 0;
87
+ const fp = Number(raw.fp) || 0;
88
+ const n = tp + fp;
89
+ if (n === 0) continue;
90
+ const calibrated = n >= MIN_SAMPLES_FOR_CALIBRATION ? tp / n : null;
91
+ const ci95 = wilsonInterval(tp, n);
92
+ out[fam] = { tp, fp, n, calibrated, ci95 };
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function _readJsonMaybe(fp) {
98
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
99
+ }
100
+
101
+ // Load history from .agentic-security/validator-metrics.json + the bundled
102
+ // seed file. The bundled seed ships with this release; the customer file
103
+ // overrides per-family when N is higher there.
104
+ export function loadCalibrationHistory(scanRoot) {
105
+ const customer = _readJsonMaybe(path.join(scanRoot || process.cwd(), '.agentic-security', 'validator-metrics.json')) || {};
106
+ const seedPath = new URL('./calibration-seed.json', import.meta.url);
107
+ let seed = null;
108
+ try { seed = JSON.parse(fs.readFileSync(seedPath, 'utf8')); } catch { seed = null; }
109
+ // Merge: customer takes precedence when its sample count is higher.
110
+ const families = {};
111
+ const merge = (src) => {
112
+ const fams = src?.families || src?.perFamily || {};
113
+ for (const [k, v] of Object.entries(fams)) {
114
+ if (!v || typeof v !== 'object') continue;
115
+ const tp = Number(v.tp) || 0, fp = Number(v.fp) || 0;
116
+ const n = tp + fp;
117
+ const cur = families[k];
118
+ if (!cur || n > cur.tp + cur.fp) families[k] = { tp, fp };
119
+ }
120
+ };
121
+ if (seed) merge(seed);
122
+ if (customer) merge(customer);
123
+ return { families };
124
+ }
125
+
126
+ // ─── Annotation ──────────────────────────────────────────────────────────────
127
+ //
128
+ // For each finding, set:
129
+ // f.calibrated_confidence — number in [0,1] or null
130
+ // f.calibrated_confidence_ci — [lower, upper] or null
131
+ // f.calibrated_n — sample size used
132
+ // f.calibration_reason — when null, why ("insufficient-samples" | "no-family")
133
+
134
+ export function annotateCalibratedConfidence(findings, opts = {}) {
135
+ if (!Array.isArray(findings)) return;
136
+ const table = opts.table || buildCalibrationTable(opts.history || loadCalibrationHistory(opts.scanRoot));
137
+ for (const f of findings) {
138
+ if (!f || typeof f !== 'object') continue;
139
+ const fam = f.family || null;
140
+ if (!fam) {
141
+ f.calibrated_confidence = null;
142
+ f.calibrated_confidence_ci = null;
143
+ f.calibrated_n = 0;
144
+ f.calibration_reason = 'no-family';
145
+ continue;
146
+ }
147
+ const row = table[fam];
148
+ if (!row || typeof row.calibrated !== 'number') {
149
+ f.calibrated_confidence = null;
150
+ f.calibrated_confidence_ci = null;
151
+ f.calibrated_n = row ? row.n : 0;
152
+ f.calibration_reason = row ? 'insufficient-samples' : 'no-history';
153
+ continue;
154
+ }
155
+ f.calibrated_confidence = round3(row.calibrated);
156
+ f.calibrated_confidence_ci = [round3(row.ci95[0]), round3(row.ci95[1])];
157
+ f.calibrated_n = row.n;
158
+ f.calibration_reason = null;
159
+ }
160
+ }
161
+
162
+ function round3(x) { return Math.round(x * 1000) / 1000; }
163
+
164
+ // ─── Brier on held-out labels ───────────────────────────────────────────────
165
+ //
166
+ // Premortem #9: the previous `computeBrierFromHistory` was tautological — it
167
+ // fed (prediction = row.calibrated, actual = row.calibrated) into brierScore
168
+ // and always returned 0. That number is unsafe to surface anywhere because
169
+ // readers will interpret it as "calibration is perfect," when it actually
170
+ // measures nothing.
171
+ //
172
+ // The honest computation needs *held-out labels* — verdicts the engine did
173
+ // NOT use to fit the calibration table. We accept those as an array of
174
+ // { family, predicted, actual } where `predicted` is the calibrated rate
175
+ // the model gave (e.g. f.calibrated_confidence) and `actual ∈ {0,1}` is
176
+ // the human/system-confirmed truth.
177
+ //
178
+ // Callers must supply held-out data. There is no fallback that produces
179
+ // a number from the seed corpus alone — that path is what produced the
180
+ // tautology, and it should fail loudly instead.
181
+ export function computeBrierOnHeldOut(samples) {
182
+ if (!Array.isArray(samples) || samples.length === 0) {
183
+ return { brier: null, reason: 'no-held-out-samples' };
184
+ }
185
+ const cleaned = [];
186
+ for (const s of samples) {
187
+ if (!s || typeof s !== 'object') continue;
188
+ const p = typeof s.predicted === 'number' ? Math.max(0, Math.min(1, s.predicted)) : null;
189
+ const a = typeof s.actual === 'number'
190
+ ? (s.actual >= 0.5 ? 1 : 0)
191
+ : (s.actual === true ? 1 : s.actual === false ? 0 : null);
192
+ if (p === null || a === null) continue;
193
+ cleaned.push({ prediction: p, actual: a });
194
+ }
195
+ if (!cleaned.length) return { brier: null, reason: 'no-valid-samples' };
196
+ const brier = brierScore(cleaned);
197
+ return { brier, n: cleaned.length };
198
+ }
199
+
200
+ // Removed: computeBrierFromHistory. Anyone relying on it for a dashboard
201
+ // number should switch to computeBrierOnHeldOut(samples) with real labels.
202
+
203
+ // For tests / introspection.
204
+ export const _internals = { MIN_SAMPLES_FOR_CALIBRATION, Z_95, round3 };
@@ -0,0 +1,75 @@
1
+ // Root-cause clustering (FR-PREC-6).
2
+ //
3
+ // When a single missing sanitizer produces N flow paths converging on the
4
+ // same sink expression, the engine emits N findings. This module collapses
5
+ // them into one finding with `clusterSize` = N and `exampleFlows` containing
6
+ // up to 5 representative paths. Downstream `/fix` operates on the cluster,
7
+ // not on each leaf — one patch fixes all flows.
8
+ //
9
+ // Distinct from the existing `dedupeFindingsWithEvidence`, which clusters by
10
+ // (file, sink-line, family). Root-cause clustering goes further: it clusters
11
+ // across files when the sink shape and rule are identical, surfacing the
12
+ // "one bug, N expressions of it" view.
13
+
14
+ function sinkKey(f) {
15
+ // Cluster by the detector that fired + rule + file + a normalized form of
16
+ // the sink expression. We INTENTIONALLY do not cluster across files — the
17
+ // existing fix-bundling pipeline does that (one buggy helper called from N
18
+ // routes → one bundle of N findings). Clustering here is for the narrower
19
+ // case where a single sink has multiple flows feeding it within the same
20
+ // file.
21
+ //
22
+ // The parser tag MUST be in the key (bench-regression May 2026). Without it,
23
+ // two distinct detectors that happen to share a CWE and target the same
24
+ // sink line (e.g. structural Open Redirect + host-header redirect detector
25
+ // both flag CWE-601 on the same res.redirect line) collapse into one,
26
+ // hiding real findings. Each parser asks its own question; their findings
27
+ // are not redundant flows of the same vuln.
28
+ const parser = f.parser || '';
29
+ const rule = f.cwe || f.family || (f.vuln || '').slice(0, 40);
30
+ const file = f.file || f.sink?.file || '';
31
+ const sinkExpr = (f.sink?.label || f.sink?.snippet || f.snippet || '')
32
+ .replace(/['"`][^'"`]*['"`]/g, '_S_')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .slice(0, 120);
36
+ // Empty sinkExpr keys cluster too eagerly (rate-limit findings that don't
37
+ // carry a snippet all land in the same bucket). Skip clustering entirely
38
+ // when we have no sink expression to compare.
39
+ if (!sinkExpr) return null;
40
+ return `${parser}::${rule}::${file}::${sinkExpr}`;
41
+ }
42
+
43
+ export function clusterByRootCause(findings) {
44
+ if (!Array.isArray(findings) || findings.length === 0) return findings;
45
+ const buckets = new Map();
46
+ for (const f of findings) {
47
+ if (!f || typeof f !== 'object') continue;
48
+ const k = sinkKey(f);
49
+ if (!k) continue;
50
+ if (!buckets.has(k)) buckets.set(k, []);
51
+ buckets.get(k).push(f);
52
+ }
53
+ const drop = new Set();
54
+ for (const [, group] of buckets) {
55
+ if (group.length < 2) continue;
56
+ // Sort by severity (highest first), then by triageScore — keep the strongest.
57
+ const SEV = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
58
+ group.sort((a, b) =>
59
+ (SEV[a.severity] ?? 9) - (SEV[b.severity] ?? 9) ||
60
+ (b.triageScore || 0) - (a.triageScore || 0)
61
+ );
62
+ const keeper = group[0];
63
+ keeper.clusterSize = group.length;
64
+ keeper.exampleFlows = group.slice(1, 6).map(f => ({
65
+ file: f.file || f.sink?.file,
66
+ line: f.line || f.sink?.line,
67
+ source: f.source ? { file: f.source.file, line: f.source.line, label: f.source.label } : null,
68
+ sink: f.sink ? { file: f.sink.file, line: f.sink.line, label: f.sink.label } : null,
69
+ snippet: f.snippet,
70
+ }));
71
+ for (let i = 1; i < group.length; i++) drop.add(group[i]);
72
+ }
73
+ if (!drop.size) return findings;
74
+ return findings.filter(f => !drop.has(f));
75
+ }
@@ -0,0 +1,265 @@
1
+ // FR-SEM-9 — Bounded concurrency heuristic checker.
2
+ //
3
+ // A real model checker is research; this module ships the high-leverage
4
+ // subset: regex-level detectors for the four most common concurrency bugs
5
+ // commercial SAST misses.
6
+ //
7
+ // 1. Missed-unlock — `mutex.Lock()` / `lock.acquire()` / `synchronized` /
8
+ // `pthread_mutex_lock` without a matching unlock on every exit path
9
+ // within the same function body (early returns, exceptions).
10
+ // 2. Data race — a shared variable (file-scope or struct field) written
11
+ // from a goroutine / async task / Worker without protection from a
12
+ // detected mutex/Lock/atomic primitive.
13
+ // 3. Deadlock cycle — two functions where one acquires A then B and
14
+ // another acquires B then A. Bounded to ≤ 50 functions per scan.
15
+ // 4. Fire-and-forget — async function that mutates shared state called
16
+ // without `await` (Node), `wait()` (Python), `.get()` (futures).
17
+ //
18
+ // Languages: Go, Java, JS/TS, Python. Each pattern is conservative — we
19
+ // only emit when the surface evidence is unambiguous. Severity is medium
20
+ // by default; family `concurrency-bug`.
21
+
22
+ const PATTERNS = {
23
+ go: {
24
+ lockAcquire: /\b(\w+)\.Lock\(\)/g,
25
+ lockRelease: /\b(\w+)\.Unlock\(\)/g,
26
+ asyncStart: /\bgo\s+\w/g,
27
+ syncOnce: /\bsync\.Once\b/g,
28
+ shared: /^var\s+(\w+)\s+\w/gm,
29
+ },
30
+ java: {
31
+ lockAcquire: /\b(\w+)\.lock\(\)/g,
32
+ lockRelease: /\b(\w+)\.unlock\(\)/g,
33
+ synchronized: /\bsynchronized\s*\(/g,
34
+ asyncStart: /\bnew\s+Thread\(|\.start\(\)|@Async\b|CompletableFuture\.runAsync/g,
35
+ },
36
+ js: {
37
+ asyncFn: /\basync\s+function\s+\w+|async\s+\(/g,
38
+ asyncCallNoAwait: /^(?!.*\bawait\b).*\b\w+\s*\([^)]*\)\.then\(/gm,
39
+ workerPost: /\bworker\.postMessage\b/g,
40
+ sharedAt: /^(?:const|let|var)\s+(\w+)\s*=/gm,
41
+ },
42
+ py: {
43
+ lockAcquire: /\b(\w+)\.acquire\(\)/g,
44
+ lockRelease: /\b(\w+)\.release\(\)/g,
45
+ asyncDef: /\basync\s+def\s+\w+/g,
46
+ asyncCallNoAwait: /^(?!.*\bawait\b).*\basyncio\.create_task\(/gm,
47
+ },
48
+ };
49
+
50
+ function inferLang(fp) {
51
+ if (/\.go$/i.test(fp)) return 'go';
52
+ if (/\.(java|kt)$/i.test(fp)) return 'java';
53
+ if (/\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
54
+ if (/\.py$/i.test(fp)) return 'py';
55
+ return null;
56
+ }
57
+
58
+ // Walk a function body and collect lock acquire/release pairs.
59
+ // Naive: assume single-block functions. Good enough for the common case.
60
+ function extractFunctions(text, lang) {
61
+ const out = [];
62
+ let m;
63
+ if (lang === 'go') {
64
+ const re = /func(?:\s+\(\w+\s+\*?\w+\))?\s+(\w+)\s*\([^)]*\)[^{]*\{/g;
65
+ while ((m = re.exec(text))) {
66
+ const body = grabBody(text, m.index + m[0].length - 1);
67
+ if (body) out.push({ name: m[1], body, startLine: text.slice(0, m.index).split('\n').length });
68
+ }
69
+ } else if (lang === 'java') {
70
+ const re = /(?:public|private|protected|static|final|synchronized)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+[\w,\s]+)?\s*\{/g;
71
+ while ((m = re.exec(text))) {
72
+ const body = grabBody(text, m.index + m[0].length - 1);
73
+ if (body) out.push({ name: m[1], body, startLine: text.slice(0, m.index).split('\n').length });
74
+ }
75
+ } else if (lang === 'js') {
76
+ const re = /(?:function\s+(\w+)\s*\(|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>)/g;
77
+ while ((m = re.exec(text))) {
78
+ const braceIdx = text.indexOf('{', m.index);
79
+ if (braceIdx < 0) continue;
80
+ const body = grabBody(text, braceIdx);
81
+ if (body) out.push({ name: m[1] || m[2], body, startLine: text.slice(0, m.index).split('\n').length });
82
+ }
83
+ } else if (lang === 'py') {
84
+ const lines = text.split('\n');
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const m2 = /^def\s+(\w+)\s*\(|^async\s+def\s+(\w+)\s*\(/.exec(lines[i]);
87
+ if (!m2) continue;
88
+ const name = m2[1] || m2[2];
89
+ const body = lines.slice(i, Math.min(i + 80, lines.length)).join('\n');
90
+ out.push({ name, body, startLine: i + 1 });
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function grabBody(text, openBraceIdx) {
97
+ if (text[openBraceIdx] !== '{') return null;
98
+ let depth = 0;
99
+ for (let i = openBraceIdx; i < Math.min(openBraceIdx + 8000, text.length); i++) {
100
+ if (text[i] === '{') depth++;
101
+ else if (text[i] === '}') {
102
+ depth--;
103
+ if (depth === 0) return text.slice(openBraceIdx, i + 1);
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function findMissedUnlocks(fn, lang) {
110
+ const out = [];
111
+ const p = PATTERNS[lang];
112
+ if (!p || !p.lockAcquire || !p.lockRelease) return out;
113
+ const acquires = [...fn.body.matchAll(p.lockAcquire)];
114
+ const releases = [...fn.body.matchAll(p.lockRelease)];
115
+ if (!acquires.length) return out;
116
+ // For each acquired lock name, check at least one release in the body.
117
+ const acquiredNames = new Set(acquires.map(m => m[1]));
118
+ const releasedNames = new Set(releases.map(m => m[1]));
119
+ for (const name of acquiredNames) {
120
+ if (!releasedNames.has(name)) {
121
+ out.push({
122
+ kind: 'missed-unlock',
123
+ lock: name,
124
+ functionName: fn.name,
125
+ startLine: fn.startLine,
126
+ });
127
+ } else {
128
+ // Lock+unlock both present, but check that the function has a defer/
129
+ // try-finally guarantee. Go: `defer`; Java/Py: try/finally; otherwise
130
+ // early-return-before-unlock is a risk.
131
+ const guarded =
132
+ (lang === 'go' && /defer\s+\w+\.Unlock\(\)/.test(fn.body)) ||
133
+ (lang === 'java' && /try\s*\{[\s\S]*finally\s*\{[\s\S]*\.unlock\(\)/m.test(fn.body)) ||
134
+ (lang === 'py' && (/with\s+\w+:/.test(fn.body) || /try\s*:[\s\S]*finally\s*:[\s\S]*\.release\(\)/m.test(fn.body)));
135
+ if (!guarded && /\breturn\b/.test(fn.body)) {
136
+ out.push({
137
+ kind: 'unguarded-lock',
138
+ lock: name,
139
+ functionName: fn.name,
140
+ startLine: fn.startLine,
141
+ remediation: lang === 'go' ? 'use `defer mu.Unlock()`' :
142
+ lang === 'java' ? 'wrap in try/finally with unlock in finally' :
143
+ lang === 'py' ? 'use `with lock:` context manager' :
144
+ 'guarantee release on every exit path',
145
+ });
146
+ }
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function findFireAndForget(fn, lang) {
153
+ const out = [];
154
+ const p = PATTERNS[lang];
155
+ if (!p || !p.asyncCallNoAwait) return out;
156
+ let m;
157
+ p.asyncCallNoAwait.lastIndex = 0;
158
+ while ((m = p.asyncCallNoAwait.exec(fn.body))) {
159
+ if (/\bvoid\s/.test(m[0])) continue; // explicit void = intentional
160
+ out.push({
161
+ kind: 'fire-and-forget',
162
+ functionName: fn.name,
163
+ startLine: fn.startLine,
164
+ snippet: m[0].slice(0, 80),
165
+ });
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function findDeadlockCycles(fns) {
171
+ // Build a graph: function → list of lock pairs acquired in order.
172
+ // Cycle if A acquires (x,y) and B acquires (y,x) somewhere — even when
173
+ // distinct calls are interleaved at runtime.
174
+ const lockOrders = [];
175
+ for (const fn of fns) {
176
+ const acquires = [...fn.body.matchAll(/\b(\w+)\.(?:Lock|lock|acquire)\(\)/g)].map(m => m[1]);
177
+ const distinct = [...new Set(acquires)];
178
+ if (distinct.length >= 2) {
179
+ lockOrders.push({ fn: fn.name, startLine: fn.startLine, pairs: pairsOf(distinct) });
180
+ }
181
+ if (lockOrders.length > 50) break; // bounded
182
+ }
183
+ const out = [];
184
+ for (let i = 0; i < lockOrders.length; i++) {
185
+ for (let j = i + 1; j < lockOrders.length; j++) {
186
+ const a = lockOrders[i], b = lockOrders[j];
187
+ for (const [x, y] of a.pairs) {
188
+ for (const [bx, by] of b.pairs) {
189
+ if (x === by && y === bx) {
190
+ out.push({
191
+ kind: 'deadlock-cycle',
192
+ functionA: a.fn,
193
+ functionB: b.fn,
194
+ order: `${a.fn} locks (${x}, ${y}); ${b.fn} locks (${bx}, ${by})`,
195
+ startLineA: a.startLine,
196
+ startLineB: b.startLine,
197
+ });
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function pairsOf(arr) {
207
+ const p = [];
208
+ for (let i = 0; i < arr.length; i++)
209
+ for (let j = i + 1; j < arr.length; j++) p.push([arr[i], arr[j]]);
210
+ return p;
211
+ }
212
+
213
+ export function scanConcurrency(fileContents) {
214
+ const findings = [];
215
+ if (!fileContents) return findings;
216
+ for (const [fp, text] of Object.entries(fileContents)) {
217
+ const lang = inferLang(fp);
218
+ if (!lang || !text) continue;
219
+ const fns = extractFunctions(text, lang);
220
+ if (!fns.length) continue;
221
+
222
+ for (const fn of fns) {
223
+ for (const bug of findMissedUnlocks(fn, lang)) {
224
+ findings.push({
225
+ id: `concurrency:${bug.kind}:${fp}:${bug.startLine}:${bug.lock}`,
226
+ file: fp,
227
+ line: bug.startLine,
228
+ vuln: bug.kind === 'missed-unlock'
229
+ ? `Concurrency: ${fn.name}() acquires ${bug.lock} but no matching unlock`
230
+ : `Concurrency: ${fn.name}() can return without releasing ${bug.lock}`,
231
+ severity: 'medium',
232
+ family: 'concurrency-bug',
233
+ confidence: 0.5,
234
+ remediation: bug.remediation || 'Release the lock on every exit path (defer / try-finally / context manager).',
235
+ });
236
+ }
237
+ for (const bug of findFireAndForget(fn, lang)) {
238
+ findings.push({
239
+ id: `concurrency:fire-forget:${fp}:${bug.startLine}`,
240
+ file: fp,
241
+ line: bug.startLine,
242
+ vuln: `Concurrency: fire-and-forget async call in ${fn.name}() — result not awaited`,
243
+ severity: 'low',
244
+ family: 'concurrency-bug',
245
+ confidence: 0.4,
246
+ remediation: 'Await the promise / call .get() on the future / use asyncio.gather.',
247
+ });
248
+ }
249
+ }
250
+
251
+ for (const bug of findDeadlockCycles(fns)) {
252
+ findings.push({
253
+ id: `concurrency:deadlock:${fp}:${bug.startLineA}-${bug.startLineB}`,
254
+ file: fp,
255
+ line: bug.startLineA,
256
+ vuln: `Concurrency: potential deadlock — ${bug.order}`,
257
+ severity: 'high',
258
+ family: 'concurrency-bug',
259
+ confidence: 0.4,
260
+ remediation: 'Acquire locks in a consistent global order across all call sites.',
261
+ });
262
+ }
263
+ }
264
+ return findings;
265
+ }
@@ -0,0 +1,65 @@
1
+ // Calibrated confidence score (0.0–1.0) per finding.
2
+ //
3
+ // Layered on top of the existing triage score, evidence count, parser type,
4
+ // and sanitizer signals. Maps the engine's various internal trust signals
5
+ // into a single normalized field that downstream consumers (Claude Code UX,
6
+ // SARIF emit, validator pipelines) can rely on.
7
+ //
8
+ // Output:
9
+ // f.confidence ∈ [0,1] — combined confidence the finding is real
10
+ // f.confidenceTier — 'high' | 'medium' | 'low' | 'very-low'
11
+ //
12
+ // Existing fields preserved (triageScore/triageLabel are unchanged).
13
+
14
+ const PARSER_PRIOR = {
15
+ AST: 0.10, // AST detectors are precise
16
+ CHAIN: 0.12, // attack chains are confirmed by multiple findings
17
+ CONFIRMED: 0.20, // explicitly confirmed by cross-file taint
18
+ VALIDATOR: 0.25, // LLM validator accepted
19
+ };
20
+
21
+ const SEVERITY_PRIOR = {
22
+ critical: 0.85,
23
+ high: 0.75,
24
+ medium: 0.55,
25
+ low: 0.35,
26
+ info: 0.20,
27
+ };
28
+
29
+ export function annotateConfidence(findings) {
30
+ if (!Array.isArray(findings)) return;
31
+ for (const f of findings) {
32
+ if (!f || typeof f !== 'object') continue;
33
+ // If the finding already shipped with a hand-tuned confidence (e.g. jwt-exp
34
+ // emits 0.85/0.95), keep that but still normalize the tier label.
35
+ let conf = typeof f.confidence === 'number' ? f.confidence : null;
36
+ if (conf == null) {
37
+ conf = SEVERITY_PRIOR[f.severity] ?? 0.40;
38
+ // Triage score in [0,100] is the strongest signal we have today; weight it.
39
+ if (typeof f.triageScore === 'number') {
40
+ conf = 0.5 * conf + 0.5 * (f.triageScore / 100);
41
+ }
42
+ const parserBoost = PARSER_PRIOR[f.parser] || 0;
43
+ conf = Math.min(1, conf + parserBoost);
44
+ if (f.evidence && f.evidence.length > 1) conf = Math.min(1, conf + 0.05 * (f.evidence.length - 1));
45
+ if (f.sanitizerMismatch) conf = Math.min(1, conf + 0.05);
46
+ if (f.isSanitized) conf *= 0.10;
47
+ if (f.routeRooted) conf = Math.min(1, conf + 0.05);
48
+ if (f.guards && f.guards.length) conf *= 0.80;
49
+ if (f.reachable === false) conf *= 0.55;
50
+ if (f.unvalidated) conf *= 0.85; // LLM validator unavailable
51
+ if (f.llmOnly) conf *= 0.70; // LLM-only finding, no Layer-2 path
52
+ }
53
+ conf = Math.max(0, Math.min(1, conf));
54
+ f.confidence = Math.round(conf * 1000) / 1000;
55
+ // Premortem 3R-15: derive tier from the 2-decimal display value so a
56
+ // finding reported as "0.75" never lands in two tiers depending on the
57
+ // viewer's rounding. Add a +0.005 epsilon to anchor cutoffs to the
58
+ // displayed rounded value (3-decimal raw 0.745 → 2-decimal 0.75 → high).
59
+ const display = Math.round(f.confidence * 100) / 100;
60
+ if (display >= 0.75) f.confidenceTier = 'high';
61
+ else if (display >= 0.50) f.confidenceTier = 'medium';
62
+ else if (display >= 0.25) f.confidenceTier = 'low';
63
+ else f.confidenceTier = 'very-low';
64
+ }
65
+ }