@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,411 @@
1
+ // Fix history — preview, apply, undo for auto-fixes.
2
+ //
3
+ // Every applied fix:
4
+ // 1. Saves the original file contents to .agentic-security/fix-history/<id>.bak
5
+ // 2. Records {findingId, file, originalSha256, appliedAt, ruleId} in
6
+ // .agentic-security/fix-history/log.json
7
+ //
8
+ // `agentic-security undo` reverts the most recent applied fix (or `--all`
9
+ // to revert every fix in the log, in reverse order).
10
+
11
+ import * as fs from 'node:fs';
12
+ import * as fsp from 'node:fs/promises';
13
+ import * as path from 'node:path';
14
+ import * as crypto from 'node:crypto';
15
+
16
+ function historyDir(scanRoot) {
17
+ return path.join(scanRoot, '.agentic-security', 'fix-history');
18
+ }
19
+ function logPath(scanRoot) { return path.join(historyDir(scanRoot), 'log.json'); }
20
+
21
+ function ensure(scanRoot) { fs.mkdirSync(historyDir(scanRoot), { recursive: true }); }
22
+
23
+ export function readLog(scanRoot) {
24
+ const fp = logPath(scanRoot);
25
+ if (!fs.existsSync(fp)) return [];
26
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return []; }
27
+ }
28
+ function writeLog(scanRoot, log) {
29
+ ensure(scanRoot);
30
+ fs.writeFileSync(logPath(scanRoot), JSON.stringify(log, null, 2));
31
+ }
32
+ function sha(s) { return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16); }
33
+
34
+ // Premortem 3R-12: cross-check helpers for last-scan.json. We look up
35
+ // findings by `id` (the finding's canonical key from the engine) so we can
36
+ // stash the corresponding stableId on the fix entry and verify in recover().
37
+ function _lastScanPath(scanRoot) {
38
+ return path.join(scanRoot, '.agentic-security', 'last-scan.json');
39
+ }
40
+ function _readLastScan(scanRoot) {
41
+ const fp = _lastScanPath(scanRoot);
42
+ if (!fs.existsSync(fp)) return null;
43
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
44
+ }
45
+ function _allFindings(scan) {
46
+ if (!scan || typeof scan !== 'object') return [];
47
+ return [
48
+ ...(scan.findings || []),
49
+ ...(scan.logicVulns || []),
50
+ ...(scan.secrets || []),
51
+ ...(scan.sca || []),
52
+ ...(scan.iac || []),
53
+ ];
54
+ }
55
+ function _lookupStableId(scanRoot, findingId) {
56
+ const scan = _readLastScan(scanRoot);
57
+ if (!scan) return null;
58
+ for (const f of _allFindings(scan)) {
59
+ if (f && f.id === findingId) return f.stableId || null;
60
+ }
61
+ return null;
62
+ }
63
+ function _findingStillPresent(scanRoot, entry) {
64
+ const scan = _readLastScan(scanRoot);
65
+ if (!scan) return null; // unknown — caller treats as "skip cross-check"
66
+ for (const f of _allFindings(scan)) {
67
+ if (!f) continue;
68
+ if (entry.stableId && f.stableId && f.stableId === entry.stableId) return true;
69
+ if (entry.findingId && f.id === entry.findingId) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ // Premortem 3R-13: writing fix-history/log.json from concurrent
75
+ // applyFix / recover() invocations can interleave and corrupt the JSON. We
76
+ // use an exclusive (wx) lockfile under the history dir; whoever creates it
77
+ // wins, others spin briefly. The lock is released in finally{}. Stale
78
+ // locks > 30s are reaped on contention.
79
+ async function _withLogLock(scanRoot, fn) {
80
+ ensure(scanRoot);
81
+ const lockPath = path.join(historyDir(scanRoot), 'log.lock');
82
+ const startedAt = Date.now();
83
+ const TIMEOUT_MS = 5000;
84
+ while (true) {
85
+ try {
86
+ const handle = await fsp.open(lockPath, 'wx');
87
+ await handle.writeFile(String(process.pid));
88
+ try { await handle.close(); } catch {}
89
+ try {
90
+ return await fn();
91
+ } finally {
92
+ try { await fsp.unlink(lockPath); } catch {}
93
+ }
94
+ } catch (e) {
95
+ if (e && e.code === 'EEXIST') {
96
+ // Premortem 4R-9: stale-lock reap is now PID-aware. We read the PID
97
+ // from the lock file and check whether the process still exists. If
98
+ // the PID is dead OR the lock is older than 30s AND the PID isn't
99
+ // alive, only THEN do we unlink. This prevents racing the unlink
100
+ // against a fresh lock from another process on flaky filesystems.
101
+ try {
102
+ const [st, pidStr] = await Promise.all([
103
+ fsp.stat(lockPath),
104
+ fsp.readFile(lockPath, 'utf8').catch(() => ''),
105
+ ]);
106
+ const pid = parseInt(pidStr.trim(), 10);
107
+ const pidAlive = Number.isFinite(pid) && _isProcessAlive(pid);
108
+ const old = Date.now() - st.mtimeMs > 30000;
109
+ if (!pidAlive || old) {
110
+ try {
111
+ // Atomic-ish reap: only unlink if the lockfile still contains
112
+ // the same PID we just read (i.e. nobody else replaced it).
113
+ const recheck = (await fsp.readFile(lockPath, 'utf8').catch(() => '')).trim();
114
+ if (recheck === pidStr.trim()) {
115
+ await fsp.unlink(lockPath);
116
+ }
117
+ } catch {}
118
+ continue;
119
+ }
120
+ } catch {}
121
+ if (Date.now() - startedAt > TIMEOUT_MS) {
122
+ throw new Error('fix-history: log lock timed out');
123
+ }
124
+ await new Promise(r => setTimeout(r, 25));
125
+ continue;
126
+ }
127
+ throw e;
128
+ }
129
+ }
130
+ }
131
+
132
+ function _isProcessAlive(pid) {
133
+ // POSIX: process.kill(pid, 0) probes existence without sending a signal.
134
+ // EPERM also means the process exists; only ESRCH means dead.
135
+ try { process.kill(pid, 0); return true; }
136
+ catch (e) { return e && e.code === 'EPERM'; }
137
+ }
138
+
139
+ // Build a unified-diff-ish preview between two strings, with line numbers.
140
+ // Not a real `diff -u`, but readable enough for the vibecoder use case.
141
+ export function preview(originalContent, newContent, file) {
142
+ const a = originalContent.split('\n');
143
+ const b = newContent.split('\n');
144
+ const max = Math.max(a.length, b.length);
145
+ const out = [`--- ${file} (before)`, `+++ ${file} (after)`];
146
+ let firstDiff = -1, lastDiff = -1;
147
+ for (let i = 0; i < max; i++) {
148
+ if ((a[i] || '') !== (b[i] || '')) {
149
+ if (firstDiff < 0) firstDiff = i;
150
+ lastDiff = i;
151
+ }
152
+ }
153
+ if (firstDiff < 0) { out.push('(no changes)'); return out.join('\n'); }
154
+ const ctx = 3;
155
+ const start = Math.max(0, firstDiff - ctx);
156
+ const end = Math.min(max, lastDiff + ctx + 1);
157
+ for (let i = start; i < end; i++) {
158
+ const sa = a[i], sb = b[i];
159
+ if (sa === sb) out.push(` ${String(i + 1).padStart(4)} ${sa ?? ''}`);
160
+ else {
161
+ if (sa !== undefined) out.push(`- ${String(i + 1).padStart(4)} ${sa}`);
162
+ if (sb !== undefined) out.push(`+ ${String(i + 1).padStart(4)} ${sb}`);
163
+ }
164
+ }
165
+ return out.join('\n');
166
+ }
167
+
168
+ // Apply a fix and record it in history. Two-phase commit (premortem P2-9):
169
+ //
170
+ // 1. Write the backup file + fsync.
171
+ // 2. Write the log with the entry marked status='pending' + fsync.
172
+ // 3. Write the new file content + fsync.
173
+ // 4. Update the log entry to status='applied' + fsync.
174
+ //
175
+ // If we crash between (1) and (3) — backup exists, log entry says 'pending',
176
+ // file is untouched. `recover()` rolls forward by deleting the pending entry.
177
+ // If we crash between (3) and (4) — backup exists, log entry says 'pending',
178
+ // file IS the new content. `recover()` checks file hash; if it matches newSha
179
+ // the entry is promoted to 'applied'; if it matches originalSha it's dropped.
180
+ //
181
+ // This guarantees the file is never modified without a corresponding
182
+ // recoverable log entry.
183
+ // Harness-engineering note (post-derived): hard step budget. The same
184
+ // stableId / findingId can be attempted at most MAX_ATTEMPTS times before
185
+ // the deterministic layer refuses. This prevents a misbehaving agent (or a
186
+ // rule whose canonical fix is wrong for this codebase) from chewing through
187
+ // turns retrying the same broken patch. Override via env var only — there
188
+ // is no per-call override.
189
+ const MAX_ATTEMPTS_PER_KEY = (() => {
190
+ const v = parseInt(process.env.AGENTIC_SECURITY_FIX_MAX_ATTEMPTS || '2', 10);
191
+ return Number.isFinite(v) && v >= 1 ? v : 2;
192
+ })();
193
+
194
+ export class FixAttemptBudgetExceededError extends Error {
195
+ constructor(key, attempts, max) {
196
+ super(`fix attempts for ${key} exceeded budget (${attempts} >= ${max}). The canonical fix is wrong for this codebase; surface to a human.`);
197
+ this.name = 'FixAttemptBudgetExceededError';
198
+ this.key = key; this.attempts = attempts; this.max = max;
199
+ }
200
+ }
201
+
202
+ function _countPriorAttempts(log, stableId, findingId) {
203
+ let n = 0;
204
+ for (const e of log) {
205
+ if (e.reverted) continue; // a clean revert resets the count
206
+ if (stableId && e.stableId === stableId) { n++; continue; }
207
+ if (findingId && e.findingId === findingId) { n++; }
208
+ }
209
+ return n;
210
+ }
211
+
212
+ export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId }) {
213
+ return _withLogLock(scanRoot, async () => {
214
+ ensure(scanRoot);
215
+ const absFile = path.resolve(scanRoot, file);
216
+ const id = `fix-${Date.now().toString(36)}-${sha(file + findingId).slice(0, 6)}`;
217
+ const bakPath = path.join(historyDir(scanRoot), `${id}.bak`);
218
+ const resolvedStableId = stableId || _lookupStableId(scanRoot, findingId);
219
+ // Budget check BEFORE backup, so we don't accumulate dead .bak files
220
+ // for refused attempts.
221
+ const priorLog = readLog(scanRoot);
222
+ const priorAttempts = _countPriorAttempts(priorLog, resolvedStableId, findingId);
223
+ if (priorAttempts >= MAX_ATTEMPTS_PER_KEY) {
224
+ throw new FixAttemptBudgetExceededError(
225
+ resolvedStableId || findingId || '(unknown-key)',
226
+ priorAttempts,
227
+ MAX_ATTEMPTS_PER_KEY,
228
+ );
229
+ }
230
+ // Phase 1: backup + fsync.
231
+ await _writeAndSync(bakPath, originalContent);
232
+ const entry = {
233
+ id,
234
+ findingId,
235
+ stableId: resolvedStableId || null,
236
+ ruleId: ruleId || null,
237
+ vuln: vuln || null,
238
+ file,
239
+ backupPath: path.relative(scanRoot, bakPath),
240
+ originalSha: sha(originalContent),
241
+ newSha: sha(newContent),
242
+ appliedAt: new Date().toISOString(),
243
+ status: 'pending',
244
+ reverted: false,
245
+ attemptOrdinal: priorAttempts + 1,
246
+ };
247
+ // Phase 2: log entry marked pending + fsync.
248
+ const log = priorLog;
249
+ log.push(entry);
250
+ await _writeLogAndSync(scanRoot, log);
251
+ // Phase 3: write the new content to the target file + fsync.
252
+ try {
253
+ await _writeAndSync(absFile, newContent);
254
+ } catch (e) {
255
+ entry.status = 'failed';
256
+ entry.error = e.message;
257
+ await _writeLogAndSync(scanRoot, log);
258
+ throw e;
259
+ }
260
+ // Phase 4: promote to applied.
261
+ entry.status = 'applied';
262
+ await _writeLogAndSync(scanRoot, log);
263
+ return entry;
264
+ });
265
+ }
266
+
267
+ async function _writeAndSync(fp, content) {
268
+ await fsp.mkdir(path.dirname(fp), { recursive: true });
269
+ const handle = await fsp.open(fp, 'w');
270
+ try {
271
+ await handle.writeFile(content);
272
+ if (typeof handle.sync === 'function') await handle.sync();
273
+ } finally {
274
+ await handle.close();
275
+ }
276
+ }
277
+
278
+ async function _writeLogAndSync(scanRoot, log) {
279
+ ensure(scanRoot);
280
+ const fp = logPath(scanRoot);
281
+ const handle = await fsp.open(fp, 'w');
282
+ try {
283
+ await handle.writeFile(JSON.stringify(log, null, 2));
284
+ if (typeof handle.sync === 'function') await handle.sync();
285
+ } finally {
286
+ await handle.close();
287
+ }
288
+ }
289
+
290
+ // Recover from a crash mid-applyFix. Reads the log, examines any 'pending'
291
+ // entries, compares the file's current sha against entry.newSha / .originalSha,
292
+ // and either promotes to 'applied' or drops the entry. Returns the recovered
293
+ // entries.
294
+ export async function recover(scanRoot) {
295
+ return _withLogLock(scanRoot, () => _recoverInner(scanRoot));
296
+ }
297
+
298
+ async function _recoverInner(scanRoot) {
299
+ const log = readLog(scanRoot);
300
+ const recovered = [];
301
+ for (const e of log) {
302
+ if (e.status !== 'pending') continue;
303
+ const absFile = path.resolve(scanRoot, e.file);
304
+ let curr;
305
+ try { curr = await fsp.readFile(absFile, 'utf8'); }
306
+ catch { e.status = 'failed'; e.error = 'file-missing'; recovered.push(e); continue; }
307
+ const currSha = sha(curr);
308
+ if (currSha === e.newSha) {
309
+ // Premortem 3R-12: before blindly promoting a pending fix to applied,
310
+ // cross-check that the finding is still recognized by last-scan.json.
311
+ // If last-scan was re-run during the crash and the issue has vanished
312
+ // (fixed externally, file refactored away), we record that ambiguity
313
+ // rather than tagging this as a successful auto-fix.
314
+ const stillPresent = _findingStillPresent(scanRoot, e);
315
+ if (stillPresent === false) {
316
+ e.status = 'applied-stale';
317
+ e.error = 'finding-not-in-last-scan';
318
+ } else {
319
+ e.status = 'applied';
320
+ }
321
+ e.recoveredAt = new Date().toISOString();
322
+ recovered.push(e);
323
+ } else if (currSha === e.originalSha) {
324
+ e.status = 'failed';
325
+ e.error = 'file-untouched-during-crash';
326
+ e.recoveredAt = new Date().toISOString();
327
+ recovered.push(e);
328
+ } else {
329
+ e.status = 'failed';
330
+ e.error = `file-content-mismatch-curr-sha=${currSha}`;
331
+ e.recoveredAt = new Date().toISOString();
332
+ recovered.push(e);
333
+ }
334
+ }
335
+ if (recovered.length) await _writeLogAndSync(scanRoot, log);
336
+ return recovered;
337
+ }
338
+
339
+ // Revert the most recent un-reverted fix. Returns the entry or null.
340
+ export async function undoLast(scanRoot) {
341
+ return _withLogLock(scanRoot, async () => {
342
+ const log = readLog(scanRoot);
343
+ for (let i = log.length - 1; i >= 0; i--) {
344
+ if (!log[i].reverted) {
345
+ const entry = log[i];
346
+ const bak = path.resolve(scanRoot, entry.backupPath);
347
+ const absFile = path.resolve(scanRoot, entry.file);
348
+ if (!fs.existsSync(bak)) return { error: `backup missing: ${bak}` };
349
+ const original = await fsp.readFile(bak, 'utf8');
350
+ await fsp.writeFile(absFile, original);
351
+ entry.reverted = true;
352
+ entry.revertedAt = new Date().toISOString();
353
+ writeLog(scanRoot, log);
354
+ return entry;
355
+ }
356
+ }
357
+ return null;
358
+ });
359
+ }
360
+
361
+ // Revert everything that hasn't been reverted, in reverse order.
362
+ export async function undoAll(scanRoot) {
363
+ const reverted = [];
364
+ let r;
365
+ while ((r = await undoLast(scanRoot)) && !r.error) reverted.push(r);
366
+ return reverted;
367
+ }
368
+
369
+ export function listHistory(scanRoot) { return readLog(scanRoot); }
370
+
371
+ // Premortem 3R-17: fix-history/log.json grows monotonically. A long-running
372
+ // project will accumulate thousands of entries over years. We compact by
373
+ // archiving entries older than the retention window and reverted entries
374
+ // to log-archive-<YYYY-MM>.json, leaving only "fresh" (active or recent)
375
+ // entries in the active log. .bak files referenced by archived entries
376
+ // can be optionally pruned (only when `--prune-backups` flag is set,
377
+ // since their absence would break undo).
378
+ export async function compactLog(scanRoot, opts = {}) {
379
+ return _withLogLock(scanRoot, async () => {
380
+ const retainDays = typeof opts.retainDays === 'number' ? opts.retainDays : 90;
381
+ const pruneBackups = !!opts.pruneBackups;
382
+ const cutoff = Date.now() - retainDays * 24 * 60 * 60 * 1000;
383
+ const log = readLog(scanRoot);
384
+ const keep = [];
385
+ const archive = [];
386
+ for (const e of log) {
387
+ const tsStr = e.recoveredAt || e.revertedAt || e.appliedAt;
388
+ const ts = tsStr ? Date.parse(tsStr) : Date.now();
389
+ const old = isFinite(ts) && ts < cutoff;
390
+ const terminal = e.reverted === true || e.status === 'failed' || e.status === 'applied-stale';
391
+ if (old && terminal) archive.push(e);
392
+ else keep.push(e);
393
+ }
394
+ if (archive.length) {
395
+ const month = new Date().toISOString().slice(0, 7);
396
+ const archivePath = path.join(historyDir(scanRoot), `log-archive-${month}.json`);
397
+ let prior = [];
398
+ try { prior = JSON.parse(await fsp.readFile(archivePath, 'utf8')); } catch { prior = []; }
399
+ await _writeAndSync(archivePath, JSON.stringify(prior.concat(archive), null, 2));
400
+ if (pruneBackups) {
401
+ for (const e of archive) {
402
+ if (!e.backupPath) continue;
403
+ const bak = path.resolve(scanRoot, e.backupPath);
404
+ try { await fsp.unlink(bak); } catch {}
405
+ }
406
+ }
407
+ await _writeLogAndSync(scanRoot, keep);
408
+ }
409
+ return { archived: archive.length, kept: keep.length };
410
+ });
411
+ }
@@ -0,0 +1,121 @@
1
+ // Fix-plan emission for patches that exceed the safe-auto-apply bounds.
2
+ //
3
+ // PRD FR-FIX-1: a generated patch must touch ≤ 3 files and change ≤ 100 LoC.
4
+ // FR-FIX-3: when those bounds are exceeded, emit a "fix plan" — a numbered
5
+ // set of steps with file/line anchors — instead of an applyable patch.
6
+ //
7
+ // This module:
8
+ // - countPatchBounds(patch) → { files, loc, exceedsBounds }
9
+ // - renderFixPlan(finding, opts) → markdown string
10
+ // - emitFixPlanFile(scanRoot, finding, opts) → path to .agentic-security/fix-plans/<stableId>.md
11
+
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+
15
+ const MAX_FILES = 3;
16
+ const MAX_LOC = 100;
17
+
18
+ // `patch` is a map { [file]: newContent } OR a unified-diff style string.
19
+ // We accept the map form (what fix-verify uses) and the {file, replacement}
20
+ // form (what apply_fix uses).
21
+ export function countPatchBounds(patch, originalContents = {}) {
22
+ if (!patch) return { files: 0, loc: 0, exceedsBounds: false };
23
+ // Case 1: { [file]: newContent } map.
24
+ if (typeof patch === 'object' && !patch.replacement && !patch.code) {
25
+ const files = Object.keys(patch);
26
+ let loc = 0;
27
+ for (const f of files) {
28
+ const orig = originalContents[f] || '';
29
+ const nv = patch[f] || '';
30
+ loc += Math.abs(nv.split('\n').length - orig.split('\n').length);
31
+ }
32
+ return { files: files.length, loc, exceedsBounds: files.length > MAX_FILES || loc > MAX_LOC };
33
+ }
34
+ // Case 2: { file, replacement } single-file shape.
35
+ if (patch.replacement && patch.file) {
36
+ const orig = originalContents[patch.file] || '';
37
+ const loc = Math.abs(patch.replacement.split('\n').length - orig.split('\n').length);
38
+ return { files: 1, loc, exceedsBounds: loc > MAX_LOC };
39
+ }
40
+ return { files: 0, loc: 0, exceedsBounds: false };
41
+ }
42
+
43
+ // Render a markdown fix plan for a finding. The plan emits anchored steps
44
+ // the engineer (or another Claude Code turn) can follow to implement the
45
+ // architectural change manually.
46
+ export function renderFixPlan(finding, opts = {}) {
47
+ const { reason = 'patch-exceeds-bounds', bounds = null } = opts;
48
+ const head = `# Fix plan: ${finding.vuln || 'finding'}\n\n`;
49
+ const meta = [
50
+ `**Stable ID:** \`${finding.stableId || '(none)'}\``,
51
+ `**Severity:** ${finding.severity || '?'}`,
52
+ `**CWE:** ${finding.cwe || '(unmapped)'}`,
53
+ `**File:** \`${finding.file || '?'}:${finding.line || '?'}\``,
54
+ `**Confidence:** ${finding.confidence ?? '?'}`,
55
+ `**Reason:** ${reason}${bounds ? ` (touches ${bounds.files} files, Δ ${bounds.loc} LoC)` : ''}`,
56
+ ].join(' \n') + '\n\n';
57
+
58
+ const why = `## What this finding is\n\n${finding.description || finding.snippet || '(no description)'}\n\n`;
59
+
60
+ const remediation = typeof finding.remediation === 'string'
61
+ ? finding.remediation
62
+ : (finding.fix?.description || '(no remediation text on the finding)');
63
+
64
+ // Anchored steps — for non-architectural findings, this is a 3-step skeleton.
65
+ // The caller can elaborate by passing opts.steps[].
66
+ const steps = Array.isArray(opts.steps) && opts.steps.length ? opts.steps : [
67
+ {
68
+ title: `Locate the affected code`,
69
+ anchor: `${finding.file || '?'}:${finding.line || '?'}`,
70
+ detail: 'Open the file at the cited line and read ±10 lines of surrounding code so you understand the data flow.',
71
+ },
72
+ {
73
+ title: `Apply the per-rule remediation`,
74
+ anchor: null,
75
+ detail: remediation,
76
+ },
77
+ {
78
+ title: `Re-scan to confirm the finding is gone`,
79
+ anchor: null,
80
+ detail: 'Run `/scan` then check that the finding with the stable ID above is no longer present. If it persists, the patch did not address the root cause.',
81
+ },
82
+ {
83
+ title: `Run the project linter on the touched files`,
84
+ anchor: null,
85
+ detail: 'eslint / ruff / golangci-lint / checkstyle as appropriate. The auto-fixer also runs this gate before applying.',
86
+ },
87
+ ];
88
+
89
+ let body = '## Steps\n\n';
90
+ for (let i = 0; i < steps.length; i++) {
91
+ const s = steps[i];
92
+ body += `${i + 1}. **${s.title}**`;
93
+ if (s.anchor) body += ` \n _Anchor:_ \`${s.anchor}\``;
94
+ if (s.detail) body += ` \n ${s.detail.split('\n').join('\n ')}`;
95
+ body += '\n\n';
96
+ }
97
+
98
+ const footer = `---\n_Generated by agentic-security. After implementing the steps, run \`/scan\` and (when clean) commit. To clear this plan, delete this file._\n`;
99
+ return head + meta + why + body + footer;
100
+ }
101
+
102
+ // Write a fix-plan markdown file to .agentic-security/fix-plans/.
103
+ // Returns the absolute path of the written file, or null on error.
104
+ export function emitFixPlanFile(scanRoot, finding, opts = {}) {
105
+ if (!scanRoot || !finding) return null;
106
+ const dir = path.join(scanRoot, '.agentic-security', 'fix-plans');
107
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { return null; }
108
+ const id = finding.stableId || finding.id || `unknown-${Date.now().toString(36)}`;
109
+ const fp = path.join(dir, `${id}.md`);
110
+ try {
111
+ fs.writeFileSync(fp, renderFixPlan(finding, opts));
112
+ return fp;
113
+ } catch { return null; }
114
+ }
115
+
116
+ // Decide whether a finding warrants a fix-plan instead of an applyable patch.
117
+ // Used by /fix-style flows. Returns the bounds so the caller can decide.
118
+ export function shouldEmitFixPlan(finding, patch, originalContents) {
119
+ const bounds = countPatchBounds(patch, originalContents);
120
+ return { ...bounds, recommendsFixPlan: bounds.exceedsBounds };
121
+ }
@@ -0,0 +1,157 @@
1
+ // Closed-loop fix verification (v0.68).
2
+ //
3
+ // Existing `fix-verify.js` does scan + lint. This module adds the third
4
+ // leg: run the project's test suite against the patched file set. A fix
5
+ // is `verified-clean` only when:
6
+ //
7
+ // 1. Re-scan no longer fires the original finding's stableId
8
+ // 2. No new ≥medium findings introduced
9
+ // 3. Project linter (when present) passes on the patched files
10
+ // 4. Project test runner (when present) exits 0 within budget
11
+ //
12
+ // If the project has no detected test runner, we emit `untested-but-passes`
13
+ // rather than fail-closed — many small repos have no test suite and we
14
+ // don't want to refuse all fixes there. The verdict is honest.
15
+ //
16
+ // Design note: we run the tests against the WRITTEN patch, not an in-
17
+ // memory overlay — most real test runners can't be given an alternate
18
+ // filesystem cheaply. Callers are expected to apply the patch first
19
+ // (typically via fix-history.applyFix which creates a recovery backup),
20
+ // then call this. If verification fails, undoLast() rolls back.
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import { verifyFix } from './fix-verify.js';
26
+
27
+ const DEFAULT_TIMEOUT_MS = 120_000;
28
+
29
+ // Test-runner discovery. Each entry: a sentinel-file check + a command +
30
+ // args. Order matters — JS first (most common), then Python, Go, Rust,
31
+ // Java/Maven, Java/Gradle, Ruby.
32
+ function _detectRunner(scanRoot) {
33
+ const has = (p) => { try { return fs.existsSync(path.join(scanRoot, p)); } catch { return false; } };
34
+ const pkg = (() => {
35
+ try {
36
+ const raw = fs.readFileSync(path.join(scanRoot, 'package.json'), 'utf8');
37
+ return JSON.parse(raw);
38
+ } catch { return null; }
39
+ })();
40
+ if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
41
+ return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
42
+ }
43
+ if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
44
+ return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
45
+ }
46
+ if (has('go.mod')) {
47
+ return { runner: 'go-test', cmd: 'go', args: ['test', './...'] };
48
+ }
49
+ if (has('Cargo.toml')) {
50
+ return { runner: 'cargo-test', cmd: 'cargo', args: ['test', '--quiet'] };
51
+ }
52
+ if (has('Gemfile')) {
53
+ return { runner: 'rspec', cmd: 'bundle', args: ['exec', 'rspec', '--fail-fast'] };
54
+ }
55
+ if (has('pom.xml')) {
56
+ return { runner: 'maven', cmd: 'mvn', args: ['-q', 'test', '-DfailIfNoTests=false'] };
57
+ }
58
+ if (has('build.gradle') || has('build.gradle.kts')) {
59
+ return { runner: 'gradle', cmd: './gradlew', args: ['test', '--quiet', '--no-daemon'] };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ // Run the detected test runner. Honors a walltime budget. Caller may pass
65
+ // `runnerOverride` to force a specific command (rare; mostly for tests).
66
+ export function runProjectTests(scanRoot, opts = {}) {
67
+ if (!scanRoot) return { ok: true, runner: 'none', skipped: true };
68
+ const choice = opts.runnerOverride
69
+ ? { runner: opts.runnerOverride.cmd, cmd: opts.runnerOverride.cmd, args: opts.runnerOverride.args || [] }
70
+ : _detectRunner(scanRoot);
71
+ if (!choice) return { ok: true, runner: 'none', skipped: true, reason: 'no-test-runner-detected' };
72
+ const timeout = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
73
+ let r;
74
+ try {
75
+ r = spawnSync(choice.cmd, choice.args, {
76
+ cwd: scanRoot,
77
+ encoding: 'utf8',
78
+ timeout,
79
+ env: { ...process.env, CI: '1' },
80
+ });
81
+ } catch (e) {
82
+ return { ok: false, runner: choice.runner, reason: 'spawn-failed', error: e.message };
83
+ }
84
+ if (r.error && r.error.code === 'ENOENT') {
85
+ // Runner not installed — different from "tests failed". Don't fail-closed.
86
+ return { ok: true, runner: choice.runner, skipped: true, reason: 'binary-missing' };
87
+ }
88
+ if (r.status === null) {
89
+ return {
90
+ ok: false, runner: choice.runner, reason: 'timed-out',
91
+ output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
92
+ };
93
+ }
94
+ return {
95
+ ok: r.status === 0,
96
+ runner: choice.runner,
97
+ exitCode: r.status,
98
+ output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
99
+ };
100
+ }
101
+
102
+ // Closed-loop verification: scan + lint + tests. Returns a single verdict
103
+ // with per-leg detail so the caller can render a precise summary.
104
+ //
105
+ // Returns:
106
+ // {
107
+ // ok: bool,
108
+ // verdict: 'verified-clean' | 'verification-failed' | 'untested-but-passes',
109
+ // legs: { scan: …, lint: …, tests: … },
110
+ // summary: '<human-readable line>',
111
+ // }
112
+ //
113
+ // The `untested-but-passes` verdict is real and intentional: scan+lint
114
+ // passed, but no test runner was found. This is honest signal — callers
115
+ // (the security-fixer agent, downstream MCP tools) can decide whether to
116
+ // require a stronger verdict.
117
+ export async function verifyFixWithTests({
118
+ scanRoot,
119
+ originalFindingStableId,
120
+ files,
121
+ depFileContents,
122
+ runTests = true,
123
+ testRunnerOverride,
124
+ testTimeoutMs,
125
+ } = {}) {
126
+ const scanLint = await verifyFix({ scanRoot, originalFindingStableId, files, depFileContents });
127
+ const legs = {
128
+ scan: { ok: scanLint.rescan?.ok ?? scanLint.ok, detail: scanLint.rescan ?? scanLint },
129
+ lint: { ok: scanLint.lint?.ok ?? true, detail: scanLint.lint ?? null },
130
+ tests: { ok: true, detail: null, skipped: true, reason: 'not-run' },
131
+ };
132
+ if (!legs.scan.ok || !legs.lint.ok) {
133
+ return {
134
+ ok: false,
135
+ verdict: 'verification-failed',
136
+ legs,
137
+ summary: _summarize(legs, 'verification-failed'),
138
+ };
139
+ }
140
+ if (runTests) {
141
+ const tests = runProjectTests(scanRoot, { runnerOverride: testRunnerOverride, timeoutMs: testTimeoutMs });
142
+ legs.tests = { ok: tests.ok, detail: tests, skipped: !!tests.skipped, reason: tests.reason };
143
+ }
144
+ const allOk = legs.scan.ok && legs.lint.ok && legs.tests.ok;
145
+ const verdict = !allOk
146
+ ? 'verification-failed'
147
+ : (legs.tests.skipped ? 'untested-but-passes' : 'verified-clean');
148
+ return { ok: allOk, verdict, legs, summary: _summarize(legs, verdict) };
149
+ }
150
+
151
+ function _summarize(legs, verdict) {
152
+ const bits = [];
153
+ bits.push(`scan: ${legs.scan.ok ? 'pass' : 'fail'}`);
154
+ bits.push(`lint: ${legs.lint.skipped ? 'skip' : legs.lint.ok ? 'pass' : 'fail'}`);
155
+ bits.push(`tests: ${legs.tests.skipped ? 'skip' : legs.tests.ok ? 'pass' : 'fail'}`);
156
+ return `${verdict} (${bits.join(' · ')})`;
157
+ }