terramend 0.2.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 (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,908 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type } from "arktype";
4
+ import type { LocalToolContext } from "#app/mcp/localContext";
5
+ import { resolveWithinCwd } from "#app/mcp/pathSafety";
6
+ import { execute, tool, toolOk } from "#app/mcp/shared";
7
+ import {
8
+ type CostBreakdown,
9
+ classifyCostEscalation,
10
+ computeCostDelta,
11
+ infracostBaseline,
12
+ parseInfracostBreakdown,
13
+ parseInfracostResources,
14
+ runInfracostBreakdown,
15
+ } from "#app/mcp/terraform/cost";
16
+ import { checkVersionCurrency } from "#app/mcp/terraform/currency";
17
+ import {
18
+ annotateGroups,
19
+ classifyRefusal,
20
+ clusterByLocation,
21
+ computeConfidence,
22
+ docUrlsForGroup,
23
+ groupConcerns,
24
+ groupConcernsByRule,
25
+ type PreventiveControl,
26
+ planBatches,
27
+ preventiveControlFor,
28
+ ruleDocUrl,
29
+ } from "#app/mcp/terraform/decisions";
30
+ import { buildSarifReport, parseFindingsFile } from "#app/mcp/terraform/findings";
31
+ import {
32
+ aggregatePlans,
33
+ classifyDestructive,
34
+ comparePlanStability,
35
+ computeBlastRadius,
36
+ isPureMovePlan,
37
+ type PlanSummary,
38
+ parseTerraformPlanJson,
39
+ type RootPlan,
40
+ } from "#app/mcp/terraform/plan";
41
+ import {
42
+ changedTerraformFiles,
43
+ checkArgumentsAgainstSchema,
44
+ collectProviderRequirements,
45
+ computeRegressions,
46
+ computeRemediationVerdict,
47
+ runScanners,
48
+ scanFmt,
49
+ scanTflint,
50
+ scanValidate,
51
+ } from "#app/mcp/terraform/scanners";
52
+ import {
53
+ type Concern,
54
+ dedupe,
55
+ isTerraformConcern,
56
+ resolveRoots,
57
+ run,
58
+ SEVERITY_RANK,
59
+ type Severity,
60
+ skipResult,
61
+ sortConcerns,
62
+ } from "#app/mcp/terraform/types";
63
+ import { log } from "#app/utils/cli";
64
+
65
+ export const TerraformScanParams = type({
66
+ "scan_scope?": type("'full' | 'diff'").describe(
67
+ "'full' (default) scans the whole workspace; 'diff' limits concerns to Terraform files changed vs the base branch.",
68
+ ),
69
+ "severity_threshold?": type("'critical' | 'high' | 'medium' | 'low' | 'info'").describe(
70
+ "minimum severity to report (default: low). 'info' includes everything.",
71
+ ),
72
+ "group_by?": type("'file' | 'rule'").describe(
73
+ "'file' (default) makes one group per file (smaller blast radius per PR). 'rule' groups a single rule's concerns across ALL files into one group — use for sweeping, low-risk rules (e.g. 'add tags everywhere') so they become one PR instead of many.",
74
+ ),
75
+ });
76
+
77
+ export function TerraformScanTool(ctx: LocalToolContext) {
78
+ return tool({
79
+ name: "terraform_scan",
80
+ description:
81
+ "Scan the Terraform in the workspace against best practices using the deterministic check tools " +
82
+ "(terraform fmt, terraform validate, tflint, trivy, checkov). Returns a stable, severity-ranked " +
83
+ "list of `concerns` — each is one best-practice issue with a content-derived `id`, the producing " +
84
+ "`source`, `rule_id`, `severity`, the `location` (file + line), and a `remediation_hint`. Concerns " +
85
+ "are also rolled up into `groups` (one per file): different scanners flag the same defect under " +
86
+ "different rule ids, so remediate ONE group per PR (its `id` is the branch/PR key; its `concern_ids` " +
87
+ "are what the ✗→✓ re-scan must confirm cleared) rather than one PR per concern. Scanners that aren't " +
88
+ "installed are reported as skipped (they never fail the scan). Also returns `co_located` (concerns " +
89
+ "different scanners flagged at the same file:line — fix as ONE canonical change, §30), " +
90
+ "`refusal_candidates` (concerns whose fix needs a human decision — prefer a structured refusal over " +
91
+ "guessing, §29), and `prevention` (a CI guardrail per rule that stops it recurring, §21).",
92
+ parameters: TerraformScanParams,
93
+ execute: execute(async ({ scan_scope, severity_threshold, group_by }) => {
94
+ const cwd = ctx.payload.cwd ?? process.cwd();
95
+ // precedence: explicit tool arg > the run's configured severity_threshold > "low"
96
+ const configured = ctx.payload.severityThreshold as Severity | undefined;
97
+ const threshold: Severity = severity_threshold ?? configured ?? "low";
98
+ const minRank = SEVERITY_RANK[threshold];
99
+ const scope = scan_scope ?? ctx.payload.scanScope ?? "full";
100
+
101
+ const outcomes = runScanners(cwd);
102
+
103
+ // diff scope: keep only concerns in Terraform files changed vs the base.
104
+ let scopeNote: string | undefined;
105
+ let changed: Set<string> | null = null;
106
+ if (scope === "diff") {
107
+ changed = changedTerraformFiles(cwd);
108
+ if (changed === null) {
109
+ scopeNote =
110
+ "diff scope requested but the base branch could not be determined — scanned full instead";
111
+ }
112
+ }
113
+ const inScope = (c: Concern): boolean =>
114
+ changed === null
115
+ ? true
116
+ : changed.has(c.location.file.replace(/\\/g, "/").replace(/^\.\//, ""));
117
+
118
+ // §1.4 baseline: the full, severity-unfiltered concern-id set, captured
119
+ // BEFORE any fix and computed identically to verify's `current` set so the
120
+ // later regression diff (current − baseline) is apples-to-apples.
121
+ ctx.toolState.baselineConcernIds = dedupe(outcomes.flatMap((o) => o.concerns)).map(
122
+ (c) => c.id,
123
+ );
124
+
125
+ const all = sortConcerns(dedupe(outcomes.flatMap((o) => o.concerns)))
126
+ .filter(isTerraformConcern)
127
+ .filter(inScope)
128
+ .filter((c) => SEVERITY_RANK[c.severity] >= minRank);
129
+
130
+ // the reported (post-filter) set — read at end-of-run by
131
+ // finalizeSuccessRun to emit the SARIF artifact + findings outputs (§5.4).
132
+ ctx.toolState.lastScanConcerns = all;
133
+
134
+ // §3.11 grouping mode: by-file (default, smaller per-PR blast radius) or
135
+ // by-rule (one PR per rule across all files — for sweeping low-risk rules).
136
+ const grouping = group_by ?? "file";
137
+ const autonomyThreshold = (ctx.payload.autonomyThreshold as Severity | undefined) ?? "high";
138
+ const groups = annotateGroups(
139
+ grouping === "rule" ? groupConcernsByRule(all) : groupConcerns(all),
140
+ all,
141
+ autonomyThreshold,
142
+ );
143
+
144
+ // §3.10 batching plan: which auto/low-risk groups can ride one PR vs which
145
+ // must be isolated. Advisory — the agent acts on it under max_prs.
146
+ const batchPlan = planBatches(groups);
147
+
148
+ // §30 — concerns different scanners flagged at the same file:line (one
149
+ // canonical fix). §29 — concerns whose fix needs a human decision (prefer
150
+ // a structured refusal). §21 — the preventive control per distinct rule.
151
+ const coLocated = clusterByLocation(all);
152
+ const refusalCandidates = all
153
+ .map((c) => ({ id: c.id, ...classifyRefusal(c) }))
154
+ .filter((r) => r.refuse)
155
+ .map((r) => ({ concern_id: r.id, reason: r.reason }));
156
+ const prevention: Record<string, PreventiveControl> = {};
157
+ for (const c of all) {
158
+ if (prevention[c.rule_id]) continue;
159
+ const control = preventiveControlFor(c);
160
+ if (control) prevention[c.rule_id] = control;
161
+ }
162
+
163
+ const by_severity: Record<string, number> = {};
164
+ for (const c of all) by_severity[c.severity] = (by_severity[c.severity] ?? 0) + 1;
165
+
166
+ const ran = outcomes.filter((o) => o.ran).map((o) => o.source);
167
+ const skippedScanners = outcomes
168
+ .filter((o) => !o.ran)
169
+ .map((o) => ({ source: o.source, reason: o.skipped_reason }));
170
+
171
+ log.info(
172
+ `» terraform_scan: ${all.length} concern(s) ≥ ${threshold} from [${ran.join(", ")}] ` +
173
+ `(${groups.length} ${grouping}-group(s))` +
174
+ (skippedScanners.length
175
+ ? ` (skipped: ${skippedScanners.map((s) => s.source).join(", ")})`
176
+ : ""),
177
+ );
178
+
179
+ return toolOk({
180
+ scanned_dir: cwd,
181
+ scope: changed === null ? "full" : "diff",
182
+ ...(scopeNote ? { scope_note: scopeNote } : {}),
183
+ grouping,
184
+ scanners_ran: ran,
185
+ scanners_skipped: skippedScanners,
186
+ summary: { total: all.length, groups: groups.length, by_severity },
187
+ groups: groups.map((g) => ({ ...g, doc_urls: docUrlsForGroup(g, all) })),
188
+ batch_plan: batchPlan,
189
+ // §30 cross-tool co-location, §29 refusal candidates, §21 prevention.
190
+ co_located: coLocated,
191
+ refusal_candidates: refusalCandidates,
192
+ prevention,
193
+ concerns: all.map((c) => ({ ...c, doc_url: ruleDocUrl(c) })),
194
+ });
195
+ }),
196
+ });
197
+ }
198
+
199
+ export const TerraformValidateParams = type({
200
+ "paths?": type.string
201
+ .array()
202
+ .describe(
203
+ "optional list of file globs/paths to limit fmt+lint to; omit to check the whole workspace",
204
+ ),
205
+ });
206
+
207
+ export function TerraformValidateTool(ctx: LocalToolContext) {
208
+ return tool({
209
+ name: "terraform_validate",
210
+ description:
211
+ "Fast pre-PR gate. Runs `terraform fmt -check`, `terraform validate` (per Terraform root — " +
212
+ "multi-root aware, see `roots_validated`), and `tflint` over the " +
213
+ "workspace and returns whether the Terraform is well-formed and idiomatic. Also reports `providers` " +
214
+ "— the pinned provider requirements (name + source + version constraint + resolved `major`, §4.15): " +
215
+ "honour the pinned major when writing a fix, because argument names and valid blocks differ across " +
216
+ "provider majors, and a 'correct' fix for the wrong major just breaks `plan`. Call this AFTER " +
217
+ "applying a fix and BEFORE opening a PR — never open a PR whose `terraform_validate` did not pass.",
218
+ parameters: TerraformValidateParams,
219
+ execute: execute(async () => {
220
+ const cwd = ctx.payload.cwd ?? process.cwd();
221
+ // `terraform validate` runs per-root (multi-root aware); fmt + tflint are
222
+ // recursive over the whole tree.
223
+ const checks = [scanFmt(cwd), scanValidate(cwd), scanTflint(cwd)];
224
+ const remaining = sortConcerns(dedupe(checks.flatMap((c) => c.concerns)));
225
+ const ran = checks.filter((c) => c.ran).map((c) => c.source);
226
+ // count of roots where terraform ran but `validate -json` couldn't be
227
+ // parsed — a real failure, not a clean tree. We genuinely don't know if
228
+ // those roots are valid, so `passed` must fail closed below rather than
229
+ // silently treating an un-validated root as passing.
230
+ const unvalidatedRoots =
231
+ checks.find((c) => c.source === "terraform-validate")?.unvalidated ?? 0;
232
+ // §4.15 — surface the pinned provider majors so the fix targets the right
233
+ // argument schema (deterministic, read straight from required_providers).
234
+ const providers = collectProviderRequirements(cwd);
235
+ const roots = resolveRoots(cwd).map((r) => r.relDir || ".");
236
+ // §4.15-next — cross-check the arguments actually written in the workspace
237
+ // against the INSTALLED provider's schema, so an argument that's invalid
238
+ // for the pinned provider major (a "correct" fix for the wrong version) is
239
+ // caught here, before the PR, instead of surfacing as a `plan` failure.
240
+ // Deterministic and degrades green (omitted) when the schema isn't
241
+ // available (terraform not installed / dir not init-ed).
242
+ const schemaCheck = checkArgumentsAgainstSchema(cwd);
243
+ return toolOk({
244
+ // `passed` stays gated on fmt + validate + tflint only — those are
245
+ // authoritative. The schema cross-check is a high-signal ADVISORY (a
246
+ // conservative HCL parse), surfaced separately so a parser edge case can
247
+ // never wrongly block a valid fix. Fails closed when a root ran but
248
+ // couldn't be validated: an un-validated root is "unknown", not "clean".
249
+ passed: remaining.length === 0 && unvalidatedRoots === 0,
250
+ checks_ran: ran,
251
+ // true when ≥1 Terraform root could not be validated (terraform ran but
252
+ // its `-json` output was unparseable). When set, `passed` is false even
253
+ // with no remaining_issues — re-run after fixing the root's init/state,
254
+ // or inspect it by hand; do NOT treat the clean issue list as a pass.
255
+ validate_incomplete: unvalidatedRoots > 0,
256
+ remaining_issues: remaining,
257
+ providers,
258
+ // §4.15-next — arguments written in the workspace that are NOT in the
259
+ // installed provider's schema (would break `plan` on the pinned major).
260
+ // `schema_checked` is false when the schema was unavailable (terraform
261
+ // not installed / dir not init-ed) — then `unknown_arguments` is empty
262
+ // and you should rely on `terraform_plan` to catch a bad argument.
263
+ schema_checked: schemaCheck.checked,
264
+ unknown_arguments: schemaCheck.unknown_arguments,
265
+ // the Terraform roots `validate` covered (each was init+validate'd).
266
+ roots_validated: roots,
267
+ });
268
+ }),
269
+ });
270
+ }
271
+
272
+ export const TerraformVerifyRemediationParams = type({
273
+ concern_ids: type.string
274
+ .array()
275
+ .describe(
276
+ "the `concern_ids` of the group being remediated (from the original terraform_scan). the tool re-runs the scanners and reports which are now resolved vs still present.",
277
+ ),
278
+ });
279
+
280
+ export function TerraformVerifyRemediationTool(ctx: LocalToolContext) {
281
+ return tool({
282
+ name: "terraform_verify_remediation",
283
+ description:
284
+ "Deterministic ✗→✓ proof for a remediation. Re-runs the scanners and partitions the given " +
285
+ "`concern_ids` into `resolved` (gone from the re-scan) and `remaining` (still present), with a " +
286
+ "`verified` flag that is true ONLY when every id is gone. Also reports `regressions` — NEW concern " +
287
+ "ids the fix introduced that were not in the pre-fix scan (§1.4): when `has_regressions` is true the " +
288
+ "PR must be labelled `needs-human` and the new concerns listed. Finally returns a deterministic " +
289
+ "`confidence` (high/medium/low, §5.19) computed from the verification evidence (verified + no " +
290
+ "regressions + plan idempotency + blast radius + cost direction) — render it as a PR label/badge. " +
291
+ "Call this AFTER pushing the fix branch and build the PR's Validation section from its result — do " +
292
+ "NOT eyeball a scan or self-report resolution. A concern may be listed as ✓ resolved only if it " +
293
+ "appears in `resolved`.",
294
+ parameters: TerraformVerifyRemediationParams,
295
+ execute: execute(async ({ concern_ids }) => {
296
+ const cwd = ctx.payload.cwd ?? process.cwd();
297
+ const outcomes = runScanners(cwd);
298
+ const currentIds = dedupe(outcomes.flatMap((o) => o.concerns)).map((c) => c.id);
299
+ const current = new Set(currentIds);
300
+ const verdict = computeRemediationVerdict(concern_ids, current);
301
+
302
+ // §1.4 — concern ids the fix INTRODUCED (present now, absent from the
303
+ // pre-fix baseline). Only computable when terraform_scan captured a
304
+ // baseline this run; absent that, regressions are reported as unknown
305
+ // rather than falsely empty.
306
+ const baseline = ctx.toolState.baselineConcernIds;
307
+ const regressions = baseline ? computeRegressions(baseline, currentIds) : [];
308
+ const regressionsKnown = baseline !== undefined;
309
+
310
+ // §5.19 — deterministic confidence from the evidence on hand.
311
+ const confidence = computeConfidence({
312
+ verified: verdict.verified,
313
+ regressionCount: regressions.length,
314
+ idempotent: ctx.toolState.lastIdempotent,
315
+ blastTier: ctx.toolState.lastBlastTier,
316
+ costDirection: ctx.toolState.lastCostDirection,
317
+ });
318
+
319
+ const ran = outcomes.filter((o) => o.ran).map((o) => o.source);
320
+ log.info(
321
+ `» terraform_verify_remediation: ${verdict.resolved.length}/${concern_ids.length} resolved` +
322
+ ` (${verdict.remaining.length} still present` +
323
+ (regressionsKnown ? `, ${regressions.length} regression(s)` : "") +
324
+ `) — confidence: ${confidence.level} — from [${ran.join(", ")}]`,
325
+ );
326
+ return toolOk({
327
+ verified: verdict.verified,
328
+ resolved_count: verdict.resolved.length,
329
+ remaining_count: verdict.remaining.length,
330
+ resolved: verdict.resolved,
331
+ remaining: verdict.remaining,
332
+ // §1.4 regression guard
333
+ has_regressions: regressions.length > 0,
334
+ regressions,
335
+ ...(regressionsKnown
336
+ ? {}
337
+ : {
338
+ regressions_note:
339
+ "no pre-fix baseline captured (run terraform_scan first) — regressions not checked",
340
+ }),
341
+ // §5.19 confidence label
342
+ confidence: confidence.level,
343
+ confidence_reasons: confidence.reasons,
344
+ scanners_ran: ran,
345
+ });
346
+ }),
347
+ });
348
+ }
349
+
350
+ export const InfracostDiffParams = type({});
351
+
352
+ export function InfracostDiffTool(ctx: LocalToolContext) {
353
+ return tool({
354
+ name: "infracost_diff",
355
+ description:
356
+ "Estimate the monthly cost impact of the remediation. Runs Infracost on the current (fixed) " +
357
+ "Terraform and, when the base branch is resolvable, on the base version too — returning the " +
358
+ "monthly cost delta so a security fix that meaningfully raises spend can be flagged rather than " +
359
+ "merged blindly. Auto-skips (never fails) when INFRACOST_API_KEY is unset or the infracost CLI " +
360
+ "is absent — cost analysis is opt-in. Call it after the fix is committed and, when it returns " +
361
+ "`ran: true`, fold a one-line cost note into the PR body.",
362
+ parameters: InfracostDiffParams,
363
+ execute: execute(async () => {
364
+ const cwd = ctx.payload.cwd ?? process.cwd();
365
+ const key = process.env.INFRACOST_API_KEY || undefined;
366
+ if (!key) {
367
+ return skipResult(
368
+ "infracost_key_unset",
369
+ "INFRACOST_API_KEY not set — cost analysis is opt-in",
370
+ );
371
+ }
372
+ const cur = runInfracostBreakdown(cwd, key);
373
+ if (cur.missing) return skipResult("infracost_not_installed", "infracost not installed");
374
+ if (cur.status !== 0) {
375
+ return skipResult(
376
+ "infracost_failed",
377
+ `infracost breakdown failed: ${cur.stderr.trim().slice(0, 300) || "unknown error"}`,
378
+ );
379
+ }
380
+ let current: CostBreakdown;
381
+ try {
382
+ current = parseInfracostBreakdown(cur.stdout);
383
+ } catch {
384
+ return skipResult("infracost_parse_error", "could not parse infracost json output");
385
+ }
386
+ const baseline = infracostBaseline(cwd, key, ctx.tmpdir);
387
+ const delta = computeCostDelta(baseline, current);
388
+ // per-resource breakdown (top drivers) for a collapsed <details> in the PR.
389
+ const topResources = parseInfracostResources(cur.stdout).slice(0, 10);
390
+ // §5.19 — record the cost direction for the confidence label.
391
+ ctx.toolState.lastCostDirection = delta.direction;
392
+ // §4.16-next — escalate to human review when the increase crosses the
393
+ // operator's threshold.
394
+ const escalation = classifyCostEscalation(
395
+ delta.deltaMonthly,
396
+ ctx.payload.costIncreaseBlockUsd,
397
+ );
398
+ log.info(
399
+ `» infracost_diff: current ${delta.currentMonthly ?? "?"} ${delta.currency}/mo` +
400
+ (delta.deltaMonthly !== null
401
+ ? `, delta ${delta.deltaMonthly >= 0 ? "+" : ""}${delta.deltaMonthly}`
402
+ : " (no baseline)") +
403
+ (escalation.escalate ? " ⚠ COST ESCALATION (needs-human)" : ""),
404
+ );
405
+ return toolOk({
406
+ ran: true,
407
+ currency: delta.currency,
408
+ current_monthly_cost: delta.currentMonthly,
409
+ baseline_monthly_cost: delta.baselineMonthly,
410
+ monthly_delta: delta.deltaMonthly,
411
+ direction: delta.direction,
412
+ // §4.16-next — when true, label the PR needs-human (large spend increase).
413
+ needs_human: escalation.escalate,
414
+ ...(escalation.reason ? { cost_escalation_reason: escalation.reason } : {}),
415
+ // per-resource cost drivers (top 10) for a collapsed <details> block.
416
+ ...(topResources.length ? { top_resource_costs: topResources } : {}),
417
+ ...(delta.deltaMonthly === null
418
+ ? {
419
+ note: "Baseline cost unavailable (no base ref or unpriced) — reporting current monthly cost only.",
420
+ }
421
+ : {}),
422
+ });
423
+ }),
424
+ });
425
+ }
426
+
427
+ export const TerraformEmitSarifParams = type({
428
+ "output_path?": type.string.describe(
429
+ "where to write the SARIF file (default: ./terramend.sarif in the workspace). Upload it with github/codeql-action/upload-sarif to populate the repo's Security tab.",
430
+ ),
431
+ "severity_threshold?": type("'critical' | 'high' | 'medium' | 'low' | 'info'").describe(
432
+ "minimum severity to include (default: the run's configured threshold, else low).",
433
+ ),
434
+ });
435
+
436
+ export function TerraformEmitSarifTool(ctx: LocalToolContext) {
437
+ return tool({
438
+ name: "terraform_emit_sarif",
439
+ description:
440
+ "Emit the current best-practice scan as a SARIF 2.1.0 file for GitHub code-scanning (§3.5). Re-runs " +
441
+ "the scanners and writes a SARIF report (default `terramend.sarif`) that a later workflow step uploads " +
442
+ "with `github/codeql-action/upload-sarif`, surfacing every concern in the repo's Security tab with the " +
443
+ "right severity + doc link. This is the EMIT side (the inverse of `read_findings`' SARIF INGEST) — use " +
444
+ "it when the goal is to REPORT findings to code-scanning rather than open a remediation PR. Degrades " +
445
+ "green: writes an empty-result report when the tree is clean.",
446
+ parameters: TerraformEmitSarifParams,
447
+ execute: execute(async ({ output_path, severity_threshold }) => {
448
+ const cwd = ctx.payload.cwd ?? process.cwd();
449
+ const configured = ctx.payload.severityThreshold as Severity | undefined;
450
+ const threshold: Severity = severity_threshold ?? configured ?? "low";
451
+ const minRank = SEVERITY_RANK[threshold];
452
+ const outcomes = runScanners(cwd);
453
+ const concerns = sortConcerns(dedupe(outcomes.flatMap((o) => o.concerns)))
454
+ .filter(isTerraformConcern)
455
+ .filter((c) => SEVERITY_RANK[c.severity] >= minRank);
456
+ const report = buildSarifReport(concerns);
457
+ // SECURITY: confine the agent-supplied output_path to the workspace so it
458
+ // can't be used to clobber arbitrary files on the runner (the action
459
+ // process runs outside the shell sandbox). Computed BEFORE the try so an
460
+ // escape attempt surfaces as a clear error, not a "write failed" skip.
461
+ const target = resolveWithinCwd(cwd, output_path ?? "terramend.sarif");
462
+ try {
463
+ writeFileSync(target, `${JSON.stringify(report, null, 2)}\n`, "utf8");
464
+ } catch (e) {
465
+ return skipResult(
466
+ "sarif_write_failed",
467
+ `could not write SARIF to ${target}: ${e instanceof Error ? e.message : String(e)}`,
468
+ );
469
+ }
470
+ // record the agent-emitted path so the end-of-run findings-output safety
471
+ // net (finalizeSuccessRun) defers to this file instead of rewriting it.
472
+ ctx.toolState.emittedSarifPath = target;
473
+ log.info(`» terraform_emit_sarif: ${concerns.length} result(s) → ${target}`);
474
+ return toolOk({
475
+ sarif_path: target,
476
+ result_count: concerns.length,
477
+ rule_count: report.runs?.[0]?.tool?.driver?.rules?.length ?? 0,
478
+ note: "Upload with github/codeql-action/upload-sarif to populate the repo's Security tab.",
479
+ });
480
+ }),
481
+ });
482
+ }
483
+
484
+ const PLAN_JSON_ARGS = ["plan", "-input=false", "-no-color", "-lock=false", "-json"];
485
+
486
+ interface RootPlanOutcome {
487
+ ran: boolean;
488
+ skipReason?: string | undefined;
489
+ summary?: PlanSummary | undefined;
490
+ stable?: boolean | undefined;
491
+ stabilityReason?: string | undefined;
492
+ planText?: string | undefined;
493
+ }
494
+
495
+ /** init + plan (+ a stability re-plan + a human-readable plan) in a SINGLE root.
496
+ * Used by terraform_plan once per discovered root. */
497
+ function planOneRoot(absDir: string, creds: Record<string, string>): RootPlanOutcome {
498
+ const init = run("terraform", ["init", "-input=false", "-no-color"], absDir, creds);
499
+ if (init.missing) return { ran: false, skipReason: "terraform not installed" };
500
+ if (init.status !== 0) {
501
+ return {
502
+ ran: false,
503
+ skipReason: `terraform init failed: ${init.stderr.trim().slice(0, 200) || "unknown error"}`,
504
+ };
505
+ }
506
+ const plan = run("terraform", PLAN_JSON_ARGS, absDir, creds);
507
+ if (plan.status !== 0) {
508
+ return {
509
+ ran: false,
510
+ skipReason: `terraform plan failed: ${plan.stderr.trim().slice(0, 200) || "unknown error"}`,
511
+ };
512
+ }
513
+ const summary = parseTerraformPlanJson(plan.stdout);
514
+ const hasChanges =
515
+ summary.add + summary.change + summary.destroy > 0 || summary.changed.length > 0;
516
+
517
+ // §1.3 stability: re-plan once and compare (only when there's a change).
518
+ let stable = true;
519
+ let stabilityReason: string | undefined;
520
+ if (hasChanges) {
521
+ const plan2 = run("terraform", PLAN_JSON_ARGS, absDir, creds);
522
+ if (plan2.status === 0) {
523
+ const s = comparePlanStability(summary, parseTerraformPlanJson(plan2.stdout));
524
+ stable = s.stable;
525
+ stabilityReason = s.reason;
526
+ }
527
+ }
528
+ // §1.2 human-readable plan for the PR <details> block (separate non-json run).
529
+ let planText: string | undefined;
530
+ if (hasChanges) {
531
+ const readable = run(
532
+ "terraform",
533
+ ["plan", "-input=false", "-no-color", "-lock=false"],
534
+ absDir,
535
+ creds,
536
+ );
537
+ if (readable.status === 0 && readable.stdout.trim())
538
+ planText = readable.stdout.trim().slice(0, 12_000);
539
+ }
540
+ return { ran: true, summary, stable, stabilityReason, planText };
541
+ }
542
+
543
+ // env vars that signal a cloud provider credential is present — terraform plan
544
+ // needs live provider/backend access, so we only attempt it when one is set.
545
+ const CLOUD_CRED_SIGNALS = [
546
+ "AWS_ACCESS_KEY_ID",
547
+ "AWS_SECRET_ACCESS_KEY",
548
+ "AWS_PROFILE",
549
+ "AWS_ROLE_ARN",
550
+ "AWS_WEB_IDENTITY_TOKEN_FILE",
551
+ "ARM_CLIENT_ID",
552
+ "ARM_USE_OIDC",
553
+ "AZURE_CLIENT_ID",
554
+ "GOOGLE_CREDENTIALS",
555
+ "GOOGLE_APPLICATION_CREDENTIALS",
556
+ "GOOGLE_OAUTH_ACCESS_TOKEN",
557
+ ] as const;
558
+
559
+ function hasCloudCredentials(): boolean {
560
+ return CLOUD_CRED_SIGNALS.some((k) => !!process.env[k]);
561
+ }
562
+
563
+ // env vars terraform/providers legitimately consume, re-admitted past the
564
+ // secret-stripping `run()` env for the plan invocation. Terramend is BYOK
565
+ // across providers (Anthropic / OpenAI / Google Gemini / …), so NONE of those
566
+ // LLM keys may leak into the terraform subprocess. PREFIXES are only ones that
567
+ // can't collide with a provider key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
568
+ // `GEMINI_API_KEY` carry no cloud prefix; the bare `GOOGLE_` prefix is
569
+ // deliberately NOT used — it would re-admit `GOOGLE_GENERATIVE_AI_API_KEY`).
570
+ // GCP creds are matched by exact NAME / the safe `GOOGLE_CLOUD_` prefix instead.
571
+ const CLOUD_CRED_PREFIXES = [
572
+ "AWS_",
573
+ "ARM_",
574
+ "AZURE_",
575
+ "GCLOUD_",
576
+ "GOOGLE_CLOUD_",
577
+ "TF_VAR_",
578
+ "TF_TOKEN_",
579
+ "TF_CLI_",
580
+ ];
581
+ const CLOUD_CRED_NAMES = new Set([
582
+ "GOOGLE_CREDENTIALS",
583
+ "GOOGLE_APPLICATION_CREDENTIALS",
584
+ "GOOGLE_CLOUD_KEYFILE_JSON",
585
+ "GOOGLE_OAUTH_ACCESS_TOKEN",
586
+ "GOOGLE_PROJECT",
587
+ "GOOGLE_REGION",
588
+ "GOOGLE_ZONE",
589
+ "GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
590
+ ]);
591
+
592
+ // LLM/model-provider credentials that collide with a cloud PREFIX above and so
593
+ // must be explicitly denied — otherwise a BYOK key would leak into the terraform
594
+ // subprocess. `AWS_BEARER_TOKEN_BEDROCK` (Amazon Bedrock) matches `AWS_`;
595
+ // `AZURE_OPENAI_*` matches `AZURE_`. NB `AWS_REGION` (also a Bedrock env var) is
596
+ // a legitimate cloud/terraform setting and is intentionally NOT denied. Keep in
597
+ // sync with the provider `envVars` in src/models.ts.
598
+ const LLM_CRED_DENY = new Set([
599
+ "AWS_BEARER_TOKEN_BEDROCK",
600
+ "AZURE_OPENAI_API_KEY",
601
+ "AZURE_OPENAI_ENDPOINT",
602
+ ]);
603
+
604
+ export function collectCloudCredentials(): Record<string, string> {
605
+ const env: Record<string, string> = {};
606
+ for (const [k, v] of Object.entries(process.env)) {
607
+ if (v === undefined) continue;
608
+ if (LLM_CRED_DENY.has(k)) continue; // never leak a model-provider key, even if it matches a cloud prefix
609
+ if (CLOUD_CRED_PREFIXES.some((p) => k.startsWith(p)) || CLOUD_CRED_NAMES.has(k)) env[k] = v;
610
+ }
611
+ return env;
612
+ }
613
+
614
+ export const TerraformPlanParams = type({});
615
+
616
+ export function TerraformPlanTool(ctx: LocalToolContext) {
617
+ return tool({
618
+ name: "terraform_plan",
619
+ description:
620
+ "Run `terraform plan` and report the planned change summary (resources to add / change / destroy), " +
621
+ "any resource that would be DESTROYED or REPLACED, a blast-radius score (how much the fix touches), " +
622
+ "and a plan-stability check (a second plan must match the first — a perpetual-diff smell otherwise). " +
623
+ "Opt-in and degrades green — it auto-skips (returns `ran: false`, never fails the run) when no cloud " +
624
+ "credentials are detected, terraform is not installed, or init/plan can't complete (plan needs live " +
625
+ "provider/backend access). Call it after a fix to attach the real-world effect to the PR and surface " +
626
+ "destructive changes for human review. **Multi-root aware:** it plans EVERY Terraform root in the repo " +
627
+ "(`roots_planned`) and aggregates — counts summed, destructive/blast unioned — so you don't loop " +
628
+ "yourself. Returns `plan_text` (the full human-readable plan(s), for a collapsed <details> block) and " +
629
+ "`needs_human` (true when a high blast radius, a stateful destroy/replace, or a non-deterministic plan " +
630
+ "means a human must review). For an M2 modularization refactor, `refactor_safe: true` is the gate: " +
631
+ "the plan is purely `moved` state operations (every resource address preserved via `moved {}` blocks, " +
632
+ "zero add/change/destroy) — if a modularization PR's plan is NOT refactor_safe, fix the moved blocks " +
633
+ "rather than accepting resource churn.",
634
+ parameters: TerraformPlanParams,
635
+ execute: execute(async () => {
636
+ const cwd = ctx.payload.cwd ?? process.cwd();
637
+ if (!hasCloudCredentials()) {
638
+ return skipResult(
639
+ "no_cloud_credentials",
640
+ "no cloud credentials detected — terraform plan needs provider/backend access; skipped (add AWS/Azure/GCP creds or an OIDC role to enable it)",
641
+ );
642
+ }
643
+ const creds = collectCloudCredentials();
644
+
645
+ // multi-root: plan EACH root (hepcare: terraform/ + terraform/core/) and
646
+ // aggregate. resolveRoots falls back to [cwd] for a single-root repo, so
647
+ // behaviour there is identical to before.
648
+ const roots = resolveRoots(cwd);
649
+ const perRoot = roots.map((r) => ({
650
+ dir: r.relDir || ".",
651
+ outcome: planOneRoot(r.absDir, creds),
652
+ }));
653
+ const ran = perRoot.filter((p) => p.outcome.ran);
654
+ if (ran.length === 0) {
655
+ // every root skipped — surface the first reason (e.g. not installed / init failed).
656
+ const reason = perRoot[0]?.outcome.skipReason ?? "no terraform root could be planned";
657
+ const code = reason.includes("not installed")
658
+ ? "terraform_not_installed"
659
+ : reason.includes("init failed")
660
+ ? "terraform_init_failed"
661
+ : "terraform_plan_failed";
662
+ return skipResult(code, `terraform plan skipped — ${reason}`);
663
+ }
664
+
665
+ const rootPlans: RootPlan[] = ran.map((p) => ({
666
+ dir: p.dir,
667
+ summary: p.outcome.summary!,
668
+ stable: p.outcome.stable!,
669
+ }));
670
+ const agg = aggregatePlans(rootPlans);
671
+ const classified = classifyDestructive(agg.destructive);
672
+ const blastRadius = computeBlastRadius(agg.changed);
673
+ // record the UNION across roots so the push-time destroy-block guardrail
674
+ // blocks if ANY root would destroy/replace a stateful resource.
675
+ ctx.toolState.plannedDestroy = {
676
+ stateful: classified.stateful,
677
+ ephemeral: classified.ephemeral,
678
+ };
679
+ // §5.19 — record the blast tier + idempotency for the confidence label.
680
+ ctx.toolState.lastBlastTier = blastRadius.tier;
681
+ ctx.toolState.lastIdempotent = agg.idempotent;
682
+
683
+ // §1.2 — per-root human-readable plans, headed by root, for the PR <details>.
684
+ const planTextParts = ran
685
+ .filter((p) => p.outcome.planText)
686
+ .map((p) =>
687
+ ran.length > 1 ? `### Root: ${p.dir}\n${p.outcome.planText}` : p.outcome.planText,
688
+ );
689
+ const planText = planTextParts.length
690
+ ? planTextParts.join("\n\n").slice(0, 20_000)
691
+ : undefined;
692
+ const idempotencyWarning = ran.find((p) => !p.outcome.stable)?.outcome.stabilityReason;
693
+
694
+ // §2.6 → §3.9 — deterministic escalation to human review.
695
+ const escalationReasons: string[] = [];
696
+ if (blastRadius.tier === "high") {
697
+ escalationReasons.push(
698
+ `high blast radius (${blastRadius.resourceCount} resources / ${blastRadius.modules.length} modules)`,
699
+ );
700
+ }
701
+ if (classified.stateful.length > 0) {
702
+ escalationReasons.push(
703
+ `${classified.stateful.length} stateful resource(s) would be destroyed/replaced`,
704
+ );
705
+ }
706
+ if (!agg.idempotent) escalationReasons.push("non-deterministic plan (perpetual-diff smell)");
707
+
708
+ log.info(
709
+ `» terraform_plan: +${agg.add} ~${agg.change} -${agg.destroy} across ${ran.length} root(s) ` +
710
+ `[blast: ${blastRadius.tier}, ${blastRadius.resourceCount} res / ${blastRadius.modules.length} mod]` +
711
+ (agg.hasDestroyOrReplace
712
+ ? ` (DESTRUCTIVE: ${agg.destructive.length}, stateful: ${classified.stateful.length})`
713
+ : "") +
714
+ (agg.idempotent ? "" : " ⚠ UNSTABLE (non-deterministic plan)") +
715
+ (escalationReasons.length ? " ⚠ needs-human" : ""),
716
+ );
717
+ return toolOk({
718
+ ran: true,
719
+ roots_planned: ran.map((p) => p.dir),
720
+ to_add: agg.add,
721
+ to_change: agg.change,
722
+ to_destroy: agg.destroy,
723
+ has_destroy_or_replace: agg.hasDestroyOrReplace,
724
+ destructive: agg.destructive,
725
+ // data-bearing resources that would be lost — these block the push
726
+ // unless allowed via `allow_replace`.
727
+ stateful_destructive: classified.stateful,
728
+ // §2.6 — how much this fix touches; `high` should force human review.
729
+ blast_radius: blastRadius,
730
+ // §1.3 — false when any root's second plan disagreed (perpetual-diff smell).
731
+ idempotent: agg.idempotent,
732
+ idempotency_warning: idempotencyWarning,
733
+ // §M2 — state-only moves and the modularization no-op gate.
734
+ moved_count: agg.moved.length,
735
+ ...(agg.moved.length ? { moved: agg.moved.slice(0, 50) } : {}),
736
+ refactor_safe: isPureMovePlan(agg),
737
+ // §2.6 → §3.9 — deterministic escalation to human review.
738
+ needs_human: escalationReasons.length > 0,
739
+ ...(escalationReasons.length ? { needs_human_reasons: escalationReasons } : {}),
740
+ // §1.2 — full human-readable plan(s) for a collapsed <details> block.
741
+ ...(planText ? { plan_text: planText } : {}),
742
+ // roots where plan couldn't run (no backend creds for that root, etc.).
743
+ ...(perRoot.some((p) => !p.outcome.ran)
744
+ ? {
745
+ roots_skipped: perRoot
746
+ .filter((p) => !p.outcome.ran)
747
+ .map((p) => ({ dir: p.dir, reason: p.outcome.skipReason })),
748
+ }
749
+ : {}),
750
+ });
751
+ }),
752
+ });
753
+ }
754
+
755
+ export const ReadFindingsParams = type({
756
+ "path?": type.string.describe(
757
+ "path to the Assessor's findings.json. Defaults to $TERRAMEND_FINDINGS_PATH, then ./findings.json in the workspace.",
758
+ ),
759
+ "severity_threshold?": type("'critical' | 'high' | 'medium' | 'low' | 'info'").describe(
760
+ "minimum severity to report (default: the run's configured threshold, else low).",
761
+ ),
762
+ "group_by?": type("'file' | 'rule'").describe(
763
+ "'file' (default) makes one group per file; 'rule' groups a single rule across all files into one group (§3.11).",
764
+ ),
765
+ });
766
+
767
+ export function ReadFindingsTool(ctx: LocalToolContext) {
768
+ return tool({
769
+ name: "read_findings",
770
+ description:
771
+ "Load best-practice concerns from a terraform-reviewer (Assessor) findings.json INSTEAD of running " +
772
+ "the scanners. Returns the SAME { concerns, groups, summary } shape as terraform_scan, so Remediate " +
773
+ "consumes it identically. `human_only` findings and non-Terraform files are dropped. Concerns from " +
774
+ "checkov / tflint / terraform-fmt re-verify deterministically (✗→✓); findings exclusive to the reviewer " +
775
+ "(tfsec / infracost / llm) carry source `reviewer` and can't be reproduced by Terramend's scanners, so " +
776
+ "terraform_verify_remediation will report them unresolved — rely on terraform_validate + your explanation " +
777
+ "for those. Returns `found: false` (never an error) when no findings.json is present.",
778
+ parameters: ReadFindingsParams,
779
+ execute: execute(async ({ path, severity_threshold, group_by }) => {
780
+ const cwd = ctx.payload.cwd ?? process.cwd();
781
+ // SECURITY: confine the agent-supplied `path` to the workspace so it can't
782
+ // be used as an arbitrary file-read primitive. The env-var fallback is
783
+ // operator-controlled (not agent-controlled), so it stays unconfined.
784
+ const findingsPath = path
785
+ ? resolveWithinCwd(cwd, path)
786
+ : process.env.TERRAMEND_FINDINGS_PATH || join(cwd, "findings.json");
787
+ let raw: string;
788
+ try {
789
+ raw = readFileSync(findingsPath, "utf8");
790
+ } catch {
791
+ return skipResult(
792
+ "findings_not_found",
793
+ `no findings.json at ${findingsPath} (set the path arg or $TERRAMEND_FINDINGS_PATH)`,
794
+ { key: "found", reasonKey: "reason", extra: { concerns: [], groups: [] } },
795
+ );
796
+ }
797
+ let parsed: Concern[];
798
+ try {
799
+ // accept BOTH a terraform-reviewer findings.json AND a standard SARIF
800
+ // report (Trivy/Checkov/tflint -o sarif) — the dispatcher detects which.
801
+ parsed = parseFindingsFile(raw, cwd);
802
+ } catch {
803
+ return skipResult(
804
+ "findings_parse_error",
805
+ `could not parse findings file at ${findingsPath}`,
806
+ {
807
+ key: "found",
808
+ reasonKey: "reason",
809
+ extra: { concerns: [], groups: [] },
810
+ },
811
+ );
812
+ }
813
+
814
+ const configured = ctx.payload.severityThreshold as Severity | undefined;
815
+ const threshold: Severity = severity_threshold ?? configured ?? "low";
816
+ const minRank = SEVERITY_RANK[threshold];
817
+
818
+ // §1.4 baseline — same role as terraform_scan's, so a regression check
819
+ // after a reviewer-sourced fix has a baseline to diff against.
820
+ ctx.toolState.baselineConcernIds = dedupe(parsed).map((c) => c.id);
821
+
822
+ const all = sortConcerns(dedupe(parsed))
823
+ .filter(isTerraformConcern)
824
+ .filter((c) => SEVERITY_RANK[c.severity] >= minRank);
825
+ // §3.9 + §3.11 — group (by-file or by-rule) and annotate autonomy, exactly
826
+ // as terraform_scan does, so the rest of the Remediate checklist is
827
+ // source-agnostic.
828
+ const grouping = group_by ?? "file";
829
+ const autonomyThreshold = (ctx.payload.autonomyThreshold as Severity | undefined) ?? "high";
830
+ const groups = annotateGroups(
831
+ grouping === "rule" ? groupConcernsByRule(all) : groupConcerns(all),
832
+ all,
833
+ autonomyThreshold,
834
+ );
835
+ const batchPlan = planBatches(groups);
836
+ const by_severity: Record<string, number> = {};
837
+ for (const c of all) by_severity[c.severity] = (by_severity[c.severity] ?? 0) + 1;
838
+
839
+ log.info(
840
+ `» read_findings: ${all.length} concern(s) ≥ ${threshold} from ${findingsPath} (${groups.length} ${grouping}-group(s))`,
841
+ );
842
+
843
+ return toolOk({
844
+ found: true,
845
+ source_file: findingsPath,
846
+ grouping,
847
+ summary: { total: all.length, groups: groups.length, by_severity },
848
+ groups: groups.map((g) => ({ ...g, doc_urls: docUrlsForGroup(g, all) })),
849
+ batch_plan: batchPlan,
850
+ concerns: all.map((c) => ({ ...c, doc_url: ruleDocUrl(c) })),
851
+ });
852
+ }),
853
+ });
854
+ }
855
+
856
+ export const TerraformVersionCurrencyParams = type({});
857
+
858
+ export function TerraformVersionCurrencyTool(ctx: LocalToolContext) {
859
+ return tool({
860
+ name: "terraform_version_currency",
861
+ description:
862
+ "Check the workspace's pinned providers and registry modules against the Terraform Registry's " +
863
+ "published versions — the upgrade intelligence no scanner provides (tflint checks pins EXIST, not " +
864
+ "that they're current). Reports per provider/module the written constraint, the `latest` stable " +
865
+ "version, the newest version the constraint admits, and `outdated`/`majors_behind`; registry " +
866
+ "modules with no version pin are flagged `unpinned` (pin them to `latest`). Remediation contract " +
867
+ "(M3): one `chore(deps)` PR per upgrade group; minor/patch bumps may proceed autonomously; a MAJOR " +
868
+ "bump means the module/provider interface may have changed — apply it only with a `needs-human` " +
869
+ "label, and re-verify every bump with terraform_validate (plus terraform_plan when credentials " +
870
+ "exist; an upgrade that plans destructive changes is never auto). Network-dependent and degrades " +
871
+ "green: per-source lookup failures are reported per row, and an unreachable registry returns " +
872
+ "`ok: false` with code `registry_unreachable` (never an error).",
873
+ parameters: TerraformVersionCurrencyParams,
874
+ execute: execute(async () => {
875
+ const cwd = ctx.payload.cwd ?? process.cwd();
876
+ const report = await checkVersionCurrency(cwd);
877
+ if (report.lookups_attempted === 0) {
878
+ return toolOk({
879
+ providers: [],
880
+ modules: [],
881
+ outdated_count: 0,
882
+ unpinned_count: 0,
883
+ note: "no provider requirements or registry modules found in the workspace",
884
+ });
885
+ }
886
+ if (report.lookups_failed === report.lookups_attempted) {
887
+ return skipResult(
888
+ "registry_unreachable",
889
+ `all ${report.lookups_attempted} registry lookup(s) failed — registry.terraform.io unreachable from this runner; currency not checked`,
890
+ );
891
+ }
892
+ log.info(
893
+ `» terraform_version_currency: ${report.outdated_count} outdated, ${report.unpinned_count} unpinned ` +
894
+ `across ${report.providers.length} provider(s) + ${report.modules.length} registry module(s)` +
895
+ (report.lookups_failed ? ` (${report.lookups_failed} lookup(s) failed)` : ""),
896
+ );
897
+ return toolOk({
898
+ providers: report.providers,
899
+ modules: report.modules,
900
+ outdated_count: report.outdated_count,
901
+ unpinned_count: report.unpinned_count,
902
+ ...(report.lookups_failed > 0
903
+ ? { note: `${report.lookups_failed} lookup(s) failed — those rows report lookup != "ok"` }
904
+ : {}),
905
+ });
906
+ }),
907
+ });
908
+ }