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,465 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { ToolContext } from "#app/mcp/server";
5
+ import { log } from "#app/utils/cli";
6
+ import { resolveEnv } from "#app/utils/secrets";
7
+ import { $ } from "#app/utils/shell";
8
+
9
+ /**
10
+ * Terraform-write guardrails — hard, code-level limits that back the prompt
11
+ * rules of the modes that write Terraform and open PRs (**Remediate** and
12
+ * **GenerateTerraform**). They only engage for those modes, so every other mode
13
+ * (Build, Fix, Review, …) is completely unaffected.
14
+ */
15
+
16
+ export const REMEDIATE_MODE = "Remediate";
17
+ export const GENERATE_MODE = "GenerateTerraform";
18
+ /** §27 — the stale-fix self-healing sweep re-derives + force-updates remediation
19
+ * PRs, so it writes Terraform and pushes exactly like Remediate and is bounded by
20
+ * the same guardrails. */
21
+ export const REFRESH_REMEDIATION_MODE = "RefreshRemediation";
22
+
23
+ /** default paths these modes may modify/create: Terraform sources only. */
24
+ export const DEFAULT_ALLOWED_PATHS = ["**/*.tf", "**/*.tfvars"] as const;
25
+
26
+ /** §28 — extra paths the Terratest scaffold writes, allowed only when the
27
+ * `terratest` input is enabled (Go test files + native `*.tftest.hcl` tests fall
28
+ * outside the Terraform-only default). */
29
+ export const TERRATEST_ALLOWED_PATHS = [
30
+ "**/*_test.go",
31
+ "**/*.tftest.hcl",
32
+ "test/**",
33
+ "tests/**",
34
+ "go.mod",
35
+ "go.sum",
36
+ ] as const;
37
+
38
+ /** modes whose pushes/PRs are bounded by these guardrails. */
39
+ const GUARDED_MODES: ReadonlySet<string> = new Set([
40
+ REMEDIATE_MODE,
41
+ GENERATE_MODE,
42
+ REFRESH_REMEDIATION_MODE,
43
+ ]);
44
+
45
+ function isGuardedMode(ctx: ToolContext): boolean {
46
+ return ctx.toolState.selectedMode !== undefined && GUARDED_MODES.has(ctx.toolState.selectedMode);
47
+ }
48
+
49
+ export function resolveAllowedPaths(ctx: ToolContext): string[] {
50
+ const configured = ctx.payload.allowedPaths;
51
+ const base = configured && configured.length > 0 ? [...configured] : [...DEFAULT_ALLOWED_PATHS];
52
+ // §28 — when Terratest scaffolding is opted in, also permit the Go test +
53
+ // example-fixture paths the scaffold writes (they're outside the .tf default).
54
+ if (ctx.payload.terratest) base.push(...TERRATEST_ALLOWED_PATHS);
55
+ return base;
56
+ }
57
+
58
+ /**
59
+ * Compile a glob to an anchored RegExp. Supports `**` (any path segments,
60
+ * including the `**\/` "zero or more leading dirs" idiom), `*` (within a
61
+ * segment), and `?`. Sufficient for the path allow-list patterns
62
+ * (`**\/*.tf`, `modules/**`, `*.tfvars`).
63
+ */
64
+ export function globToRegex(glob: string): RegExp {
65
+ let re = "";
66
+ for (let i = 0; i < glob.length; i++) {
67
+ const c = glob[i]!;
68
+ if (c === "*") {
69
+ if (glob[i + 1] === "*") {
70
+ if (glob[i + 2] === "/") {
71
+ re += "(?:.*/)?";
72
+ i += 2;
73
+ } else {
74
+ re += ".*";
75
+ i += 1;
76
+ }
77
+ } else {
78
+ re += "[^/]*";
79
+ }
80
+ } else if (c === "?") {
81
+ re += "[^/]";
82
+ } else if (".+^${}()|[]\\".includes(c)) {
83
+ re += `\\${c}`;
84
+ } else {
85
+ re += c;
86
+ }
87
+ }
88
+ return new RegExp(`^${re}$`);
89
+ }
90
+
91
+ export function isPathAllowed(path: string, globs: string[]): boolean {
92
+ const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
93
+ return globs.some((g) => globToRegex(g).test(normalized));
94
+ }
95
+
96
+ /** the run-entry commit sha, used as the baseline for "what this run changed". */
97
+ function runStartSha(ctx: ToolContext): string | null {
98
+ const head = ctx.toolState.initialHead;
99
+ if (!head) return null;
100
+ try {
101
+ const ref = head.kind === "branch" ? head.name : head.sha;
102
+ return $("git", ["rev-parse", ref], { log: false });
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** files changed on the current branch since the run started. */
109
+ function changedFilesSinceRunStart(ctx: ToolContext): string[] {
110
+ const base = runStartSha(ctx);
111
+ if (!base) return [];
112
+ // `$` returns trimmed stdout on success and throws on a non-zero exit (no
113
+ // onError handler) — so a genuine git failure here propagates and the caller
114
+ // fails closed (the push is refused) rather than treating an errored diff as
115
+ // "nothing changed". `git diff --name-only` exits 0 on a clean diff.
116
+ const out = $("git", ["diff", "--name-only", base, "HEAD"], { log: false });
117
+ return out
118
+ .split("\n")
119
+ .map((l) => l.trim())
120
+ .filter(Boolean);
121
+ }
122
+
123
+ /**
124
+ * Enforce the path allow-list before a Remediate-mode push. Throws if the
125
+ * branch changed any file outside the allowed globs — the choke point is
126
+ * push_branch, the only way changes reach a PR. Fails closed: if the baseline
127
+ * can't be established it refuses rather than letting an unbounded change
128
+ * through.
129
+ */
130
+ export function enforceRemediationPaths(ctx: ToolContext): void {
131
+ if (!isGuardedMode(ctx)) return;
132
+
133
+ const base = runStartSha(ctx);
134
+ if (!base) {
135
+ throw new Error(
136
+ "push blocked (Terraform-only guardrail): could not establish the run-start commit to verify the change is limited to Terraform files. " +
137
+ "Ensure the run started from a clean checkout.",
138
+ );
139
+ }
140
+
141
+ const allowed = resolveAllowedPaths(ctx);
142
+ const changed = changedFilesSinceRunStart(ctx);
143
+ const violations = changed.filter((f) => !isPathAllowed(f, allowed));
144
+ if (violations.length > 0) {
145
+ throw new Error(
146
+ `push blocked (Terraform-only guardrail): this run changed files outside the allowed paths [${allowed.join(", ")}]. ` +
147
+ `This mode must only touch Terraform files. Revert these and keep the change to Terraform only:\n` +
148
+ violations.map((v) => ` - ${v}`).join("\n"),
149
+ );
150
+ }
151
+ log.info(
152
+ `» Terraform-only path guardrail ok (${changed.length} file(s), all within [${allowed.join(", ")}])`,
153
+ );
154
+ }
155
+
156
+ // --- §2.7 protected-resource allowlist -------------------------------------
157
+
158
+ /** glob patterns marking files the fixer must NEVER auto-modify (prod state,
159
+ * data stores, anything sensitive). The inverse of `allowed_paths`. */
160
+ export function resolveProtectedPaths(ctx: ToolContext): string[] {
161
+ return ctx.payload.protectedPaths ?? [];
162
+ }
163
+
164
+ /**
165
+ * Block a push that touched any file matching `protected_paths`. This is the
166
+ * inverse of the allow-list: a changed file matching a protected glob fails the
167
+ * push, even though it's a `.tf`/`.tfvars` the allow-list would otherwise permit.
168
+ * No-op when `protected_paths` is unset or outside a guarded mode. Fails closed:
169
+ * if the run-start baseline can't be established it refuses, same as
170
+ * `enforceRemediationPaths`.
171
+ */
172
+ export function enforceProtectedPaths(ctx: ToolContext): void {
173
+ if (!isGuardedMode(ctx)) return;
174
+ const protectedGlobs = resolveProtectedPaths(ctx);
175
+ if (protectedGlobs.length === 0) return;
176
+
177
+ const base = runStartSha(ctx);
178
+ if (!base) {
179
+ throw new Error(
180
+ "push blocked (protected-paths guardrail): could not establish the run-start commit to verify no protected path was modified. " +
181
+ "Ensure the run started from a clean checkout.",
182
+ );
183
+ }
184
+
185
+ const changed = changedFilesSinceRunStart(ctx);
186
+ const violations = changed.filter((f) => isPathAllowed(f, protectedGlobs));
187
+ if (violations.length > 0) {
188
+ throw new Error(
189
+ `push blocked (protected-paths guardrail): this run modified files matching the protected_paths globs [${protectedGlobs.join(", ")}], ` +
190
+ `which are marked never-auto-modify. Revert these and leave them for a human:\n` +
191
+ violations.map((v) => ` - ${v}`).join("\n"),
192
+ );
193
+ }
194
+ log.info(`» protected-paths guardrail ok (no change matched [${protectedGlobs.join(", ")}])`);
195
+ }
196
+
197
+ // --- §2.8 secrets-safe diff scan -------------------------------------------
198
+
199
+ export interface SecretHit {
200
+ file: string;
201
+ line: number;
202
+ rule: string;
203
+ }
204
+
205
+ /**
206
+ * High-signal secret detectors applied to lines a fix ADDED. Kept deliberately
207
+ * narrow (low false-positive) — the goal is to stop a "fix" that hardcodes a
208
+ * literal credential (e.g. resolving a "use a variable" finding by pasting the
209
+ * secret), not to be a general-purpose scanner. Each entry is [rule, regex].
210
+ */
211
+ const SECRET_VALUE_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
212
+ ["aws-access-key-id", /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[0-9A-Z]{16}\b/],
213
+ ["pem-private-key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/],
214
+ ["gcp-api-key", /\bAIza[0-9A-Za-z_-]{35}\b/],
215
+ ["github-token", /\bgh[pousr]_[0-9A-Za-z]{36,}\b/],
216
+ ["slack-token", /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/],
217
+ ["private-key-pem-block", /-----BEGIN PRIVATE KEY-----/],
218
+ ];
219
+
220
+ // an HCL/tfvars assignment of a STRING LITERAL to a secret-named attribute, e.g.
221
+ // `password = "hunter2"` or `secret_access_key = "AKIA..."`. Excludes references
222
+ // (`= var.x`, `= "${...}"`, `= local.y`) and empty strings — only a real inlined
223
+ // literal trips it.
224
+ const SENSITIVE_ASSIGNMENT =
225
+ /\b(?:password|passwd|secret|secret_key|secret_access_key|access_key|api_key|apikey|auth_token|access_token|private_key|client_secret|credential|connection_string)\b\s*[=:]\s*"([^"$][^"]*)"/i;
226
+
227
+ /**
228
+ * Scan a unified `git diff` for inlined secrets on ADDED lines only. Tracks the
229
+ * current file from `+++ b/<path>` headers and the new-side line number from
230
+ * `@@` hunk headers, so each hit carries an accurate `file:line`. Pure — the
231
+ * guardrail feeds it `git diff` output. Removed/context lines are ignored (a
232
+ * secret already in the base isn't this run's doing).
233
+ */
234
+ export function scanDiffForSecrets(diff: string): SecretHit[] {
235
+ const hits: SecretHit[] = [];
236
+ let file = "(unknown)";
237
+ let newLine = 0;
238
+ for (const raw of diff.split("\n")) {
239
+ if (raw.startsWith("+++ ")) {
240
+ const path = raw.slice(4).trim().replace(/^b\//, "");
241
+ file = path === "/dev/null" ? "(deleted)" : path;
242
+ continue;
243
+ }
244
+ if (raw.startsWith("--- ") || raw.startsWith("diff --git") || raw.startsWith("index "))
245
+ continue;
246
+ const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
247
+ if (hunk) {
248
+ newLine = Number(hunk[1]);
249
+ continue;
250
+ }
251
+ if (raw.startsWith("+")) {
252
+ const content = raw.slice(1);
253
+ for (const [rule, re] of SECRET_VALUE_PATTERNS) {
254
+ if (re.test(content)) hits.push({ file, line: newLine, rule });
255
+ }
256
+ if (SENSITIVE_ASSIGNMENT.test(content)) {
257
+ hits.push({ file, line: newLine, rule: "hardcoded-secret-assignment" });
258
+ }
259
+ newLine++;
260
+ } else if (raw.startsWith("-")) {
261
+ // removed line — does not advance the new-side counter
262
+ } else {
263
+ // context line (leading space) or blank — advances the new-side counter
264
+ newLine++;
265
+ }
266
+ }
267
+ return hits;
268
+ }
269
+
270
+ /**
271
+ * Parse a `gitleaks detect --report-format json` report (an array of finding
272
+ * objects) into the shared `SecretHit` shape. Pure, so it's unit-testable
273
+ * without the binary. `gitleaks:` prefixes the rule so a hit's engine is
274
+ * obvious next to the built-in detectors. Tolerates an empty / non-array report.
275
+ */
276
+ export function parseGitleaksReport(json: string): SecretHit[] {
277
+ let parsed: unknown;
278
+ try {
279
+ parsed = JSON.parse(json || "[]");
280
+ } catch {
281
+ return [];
282
+ }
283
+ if (!Array.isArray(parsed)) return [];
284
+ const hits: SecretHit[] = [];
285
+ for (const f of parsed as Array<Record<string, unknown>>) {
286
+ const file = typeof f.File === "string" && f.File ? f.File : "(unknown)";
287
+ const line = typeof f.StartLine === "number" ? f.StartLine : 0;
288
+ const rule = typeof f.RuleID === "string" && f.RuleID ? f.RuleID : "secret";
289
+ hits.push({ file, line, rule: `gitleaks:${rule}` });
290
+ }
291
+ return hits;
292
+ }
293
+
294
+ /**
295
+ * Optional deeper secret scan via the external `gitleaks` binary, opt-in through
296
+ * the `gitleaks` action input. Best-effort: returns `null` when gitleaks isn't
297
+ * installed or can't run (the built-in scanner already provides the fail-closed
298
+ * baseline, so an absent gitleaks degrades to "built-in only" rather than
299
+ * failing the push). Scans the commits this run added (`<base>..HEAD`) and uses
300
+ * `--exit-code 0` so a leak doesn't make the process exit non-zero — we read the
301
+ * JSON report instead. Restricted env, so no secret leaks into the subprocess.
302
+ */
303
+ function scanWithGitleaks(ctx: ToolContext, base: string): SecretHit[] | null {
304
+ const reportPath = join(ctx.tmpdir, `gitleaks-report-${process.pid}.json`);
305
+ const cwd = ctx.payload.cwd ?? process.cwd();
306
+ const result = spawnSync(
307
+ "gitleaks",
308
+ [
309
+ "detect",
310
+ "--source",
311
+ ".",
312
+ "--log-opts",
313
+ `${base}..HEAD`,
314
+ "--report-format",
315
+ "json",
316
+ "--report-path",
317
+ reportPath,
318
+ "--exit-code",
319
+ "0",
320
+ "--no-banner",
321
+ "--redact",
322
+ ],
323
+ {
324
+ cwd,
325
+ encoding: "utf-8",
326
+ env: resolveEnv("restricted") as NodeJS.ProcessEnv,
327
+ maxBuffer: 64 * 1024 * 1024,
328
+ },
329
+ );
330
+ if (result.error) {
331
+ const missing = (result.error as NodeJS.ErrnoException).code === "ENOENT";
332
+ log.warning(
333
+ missing
334
+ ? "» gitleaks requested but not installed — falling back to the built-in secret scanner only"
335
+ : `» gitleaks could not run (${result.error.message}) — built-in secret scanner still enforced`,
336
+ );
337
+ return null;
338
+ }
339
+ try {
340
+ const report = readFileSync(reportPath, "utf8");
341
+ return parseGitleaksReport(report);
342
+ } catch {
343
+ // no report file written usually means a clean scan; treat as no hits.
344
+ return [];
345
+ } finally {
346
+ try {
347
+ rmSync(reportPath, { force: true });
348
+ } catch {
349
+ /* best-effort cleanup */
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Block a push whose diff (since run start) inlines a secret. Reuses the same
356
+ * run-start baseline as the path guardrail. No-op outside a guarded mode. Fails
357
+ * closed on a missing baseline. The diff is read with `$` (restricted env), so
358
+ * no secret leaks into the subprocess.
359
+ *
360
+ * The built-in detectors always run (the deterministic, fail-closed baseline).
361
+ * When the operator opts in via the `gitleaks` input, gitleaks ALSO runs for
362
+ * deeper coverage and its hits are merged — but its absence never weakens the
363
+ * baseline (see scanWithGitleaks).
364
+ */
365
+ export function assertNoSecretsInDiff(ctx: ToolContext): void {
366
+ if (!isGuardedMode(ctx)) return;
367
+ const base = runStartSha(ctx);
368
+ if (!base) {
369
+ throw new Error(
370
+ "push blocked (secret-scan guardrail): could not establish the run-start commit to scan the diff for inlined secrets. " +
371
+ "Ensure the run started from a clean checkout.",
372
+ );
373
+ }
374
+ const diff = $("git", ["diff", base, "HEAD"], { log: false });
375
+ const hits = scanDiffForSecrets(diff);
376
+
377
+ // optional deeper engine — merged on top of the built-in baseline.
378
+ if (ctx.payload.gitleaks) {
379
+ const gitleaksHits = scanWithGitleaks(ctx, base);
380
+ if (gitleaksHits) hits.push(...gitleaksHits);
381
+ }
382
+
383
+ if (hits.length > 0) {
384
+ throw new Error(
385
+ `push blocked (secret-scan guardrail): the change appears to inline ${hits.length} secret(s) — a fix must reference a variable/secret store, never paste a literal. ` +
386
+ `Remove or parameterise these and re-push:\n` +
387
+ hits.map((h) => ` - ${h.file}:${h.line} (${h.rule})`).join("\n"),
388
+ );
389
+ }
390
+ log.info(
391
+ `» secret-scan guardrail ok (no inlined secrets in the diff${ctx.payload.gitleaks ? ", built-in + gitleaks" : ""})`,
392
+ );
393
+ }
394
+
395
+ /** resource addresses the operator has explicitly allowed to be destroyed/replaced. */
396
+ export function resolveAllowReplace(ctx: ToolContext): string[] {
397
+ return ctx.payload.allowReplace ?? [];
398
+ }
399
+
400
+ function isReplaceAllowed(address: string, allowList: string[]): boolean {
401
+ return allowList.some(
402
+ (a) => a === "*" || a === "all" || a === address || globToRegex(a).test(address),
403
+ );
404
+ }
405
+
406
+ /**
407
+ * Block a push that `terraform_plan` showed would DELETE or REPLACE a stateful
408
+ * (data-bearing) resource — RDS, S3, EBS, a SQL database, etc. A best-practice
409
+ * remediation should never destroy data; if the replacement is genuinely
410
+ * intended the operator opts in per-resource via the `allow_replace` input
411
+ * (an address, a glob, or `*`/`all`). No-op outside guarded modes. When no plan
412
+ * ran (no cloud credentials — `terraform_plan` degraded green), there is no
413
+ * evidence to act on and nothing is blocked: this gate engages only on what the
414
+ * plan actually reported, so it strengthens the run when creds are wired and is
415
+ * silent otherwise.
416
+ */
417
+ export function assertNoBlockedDestroy(ctx: ToolContext): void {
418
+ if (!isGuardedMode(ctx)) return;
419
+ const planned = ctx.toolState.plannedDestroy;
420
+ if (!planned || planned.stateful.length === 0) return;
421
+
422
+ const allow = resolveAllowReplace(ctx);
423
+ const blocked = planned.stateful.filter((r) => !isReplaceAllowed(r.address, allow));
424
+ if (blocked.length === 0) {
425
+ log.info(
426
+ `» destroy-block guardrail ok (${planned.stateful.length} stateful destroy/replace allowed via allow_replace)`,
427
+ );
428
+ return;
429
+ }
430
+ throw new Error(
431
+ `push blocked (Terraform-only guardrail): terraform plan shows this change would DESTROY or REPLACE ` +
432
+ `${blocked.length} stateful resource(s), which would likely cause data loss — a best-practice ` +
433
+ `remediation should not. Abandon this change, or, ONLY if the replacement is genuinely intended, ` +
434
+ `set the \`allow_replace\` input to include the resource address(es):\n` +
435
+ blocked.map((r) => ` - ${r.address} (${r.action}, ${r.type})`).join("\n"),
436
+ );
437
+ }
438
+
439
+ /** maximum remediation PRs a single run may open (default 1). */
440
+ export function resolveMaxPrs(ctx: ToolContext): number {
441
+ return ctx.payload.maxPrs ?? 1;
442
+ }
443
+
444
+ /**
445
+ * Enforce the per-run PR cap before opening a remediation PR. Throws when the
446
+ * cap is already reached so the agent stops at the configured number of scoped
447
+ * PRs instead of fanning out.
448
+ */
449
+ export function assertUnderPrCap(ctx: ToolContext): void {
450
+ if (!isGuardedMode(ctx)) return;
451
+ const cap = resolveMaxPrs(ctx);
452
+ const opened = ctx.toolState.remediationPrsOpened ?? 0;
453
+ if (opened >= cap) {
454
+ throw new Error(
455
+ `PR limit reached (Terraform-only guardrail): this run is configured to open at most ${cap} PR(s) and has already opened ${opened}. ` +
456
+ `Stop here and report the remaining work for a future run.`,
457
+ );
458
+ }
459
+ }
460
+
461
+ /** record that a guarded-mode PR was opened (after create_pull_request succeeds). */
462
+ export function recordRemediationPrOpened(ctx: ToolContext): void {
463
+ if (!isGuardedMode(ctx)) return;
464
+ ctx.toolState.remediationPrsOpened = (ctx.toolState.remediationPrsOpened ?? 0) + 1;
465
+ }
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { IssueTool } from "#app/mcp/issue";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+ import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
5
+
6
+ vi.mock("#app/utils/patchWorkflowRunFields", () => ({
7
+ patchWorkflowRunFields: vi.fn(async () => undefined),
8
+ }));
9
+
10
+ type ToolResultShape = { content: [{ type: "text"; text: string }]; isError?: boolean };
11
+
12
+ async function runTool(t: { execute?: unknown }, params: unknown): Promise<ToolResultShape> {
13
+ const exec = t.execute as (p: unknown, c: unknown) => Promise<ToolResultShape>;
14
+ return exec(params, {});
15
+ }
16
+
17
+ function makeCtx(createData?: Record<string, unknown>) {
18
+ const create = vi.fn(async (_p: unknown) => ({
19
+ data: {
20
+ id: 9001,
21
+ number: 55,
22
+ node_id: "ISSUE_NODE",
23
+ html_url: "https://gh/issue/55",
24
+ title: "created title",
25
+ state: "open",
26
+ labels: [{ name: "bug" }, "needs-human"],
27
+ assignees: [{ login: "octocat" }],
28
+ ...createData,
29
+ },
30
+ }));
31
+ const ctx = {
32
+ octokit: { rest: { issues: { create } } },
33
+ repo: { owner: "octo", name: "repo" },
34
+ payload: { push: "restricted", event: { trigger: "unknown" } },
35
+ toolState: { createdTargets: new Set<number>() },
36
+ } as unknown as ToolContext;
37
+ return { ctx, create };
38
+ }
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ describe("IssueTool (create_issue)", () => {
45
+ it("creates an issue, normalizes labels/assignees, and patches the workflow run", async () => {
46
+ const { ctx, create } = makeCtx();
47
+ const result = await runTool(IssueTool(ctx), {
48
+ title: "tighten s3 policy",
49
+ body: "line1\\nline2",
50
+ labels: ["security"],
51
+ assignees: ["octocat"],
52
+ });
53
+
54
+ expect(result.isError).toBeUndefined();
55
+ expect(create).toHaveBeenCalledWith({
56
+ owner: "octo",
57
+ repo: "repo",
58
+ title: "tighten s3 policy",
59
+ body: "line1\nline2", // double-escaped newline repaired
60
+ labels: ["security"],
61
+ assignees: ["octocat"],
62
+ });
63
+ expect(patchWorkflowRunFields).toHaveBeenCalledWith(ctx, { issueNodeId: "ISSUE_NODE" });
64
+ const text = result.content[0].text;
65
+ expect(text).toContain("success: true");
66
+ expect(text).toContain("number: 55");
67
+ expect(text).toContain("bug");
68
+ expect(text).toContain("needs-human");
69
+ expect(text).toContain("octocat");
70
+ });
71
+
72
+ it("defaults labels and assignees to empty arrays", async () => {
73
+ const { ctx, create } = makeCtx();
74
+ await runTool(IssueTool(ctx), { title: "t", body: "b" });
75
+
76
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ labels: [], assignees: [] }));
77
+ });
78
+
79
+ it("skips the workflow-run patch when the node_id is missing", async () => {
80
+ const { ctx } = makeCtx({ node_id: "" });
81
+ const result = await runTool(IssueTool(ctx), { title: "t", body: "b" });
82
+
83
+ expect(result.isError).toBeUndefined();
84
+ expect(patchWorkflowRunFields).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("tolerates a response without labels/assignees arrays", async () => {
88
+ const { ctx } = makeCtx({ labels: undefined, assignees: undefined });
89
+ const result = await runTool(IssueTool(ctx), { title: "t", body: "b" });
90
+
91
+ expect(result.isError).toBeUndefined();
92
+ expect(result.content[0].text).toContain("success: true");
93
+ });
94
+
95
+ it("propagates GitHub API failures as tool errors", async () => {
96
+ const { ctx, create } = makeCtx();
97
+ create.mockRejectedValueOnce(new Error("validation failed"));
98
+ const result = await runTool(IssueTool(ctx), { title: "t", body: "b" });
99
+
100
+ expect(result.isError).toBe(true);
101
+ expect(result.content[0].text).toContain("validation failed");
102
+ });
103
+
104
+ it("is blocked under push: disabled (read-only access)", async () => {
105
+ const { ctx, create } = makeCtx();
106
+ (ctx.payload as { push: string }).push = "disabled";
107
+ const result = await runTool(IssueTool(ctx), { title: "t", body: "b" });
108
+
109
+ expect(result.isError).toBe(true);
110
+ expect(result.content[0].text).toMatch(/read-only access/);
111
+ expect(create).not.toHaveBeenCalled();
112
+ });
113
+ });
@@ -0,0 +1,73 @@
1
+ import { type } from "arktype";
2
+ import { recordCreatedTarget } from "#app/mcp/scope";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+ import { execute, tool } from "#app/mcp/shared";
5
+ import { log } from "#app/utils/cli";
6
+ import { fixDoubleEscapedString } from "#app/utils/fixDoubleEscapedString";
7
+ import { patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
8
+
9
+ export const Issue = type({
10
+ title: type.string.describe("the title of the issue"),
11
+ body: type.string.describe("the body content of the issue"),
12
+ labels: type.string
13
+ .array()
14
+ .describe("optional array of label names to apply to the issue")
15
+ .optional(),
16
+ assignees: type.string
17
+ .array()
18
+ .describe("optional array of usernames to assign to the issue")
19
+ .optional(),
20
+ });
21
+
22
+ export function IssueTool(ctx: ToolContext) {
23
+ return tool({
24
+ name: "create_issue",
25
+ description: "Create a new GitHub issue",
26
+ parameters: Issue,
27
+ execute: execute(async (params) => {
28
+ // permission gate: creating an issue is a repo write. `push: disabled`
29
+ // means read-only access, so block it (matches create_pull_request /
30
+ // push_branch) — an injected or read-only-intended agent must not be able
31
+ // to file issues.
32
+ if (ctx.payload.push === "disabled") {
33
+ throw new Error(
34
+ "Creating an issue is disabled. This repository is configured for read-only access (push: disabled).",
35
+ );
36
+ }
37
+
38
+ const result = await ctx.octokit.rest.issues.create({
39
+ owner: ctx.repo.owner,
40
+ repo: ctx.repo.name,
41
+ title: params.title,
42
+ body: fixDoubleEscapedString(params.body),
43
+ labels: params.labels ?? [],
44
+ assignees: params.assignees ?? [],
45
+ });
46
+
47
+ log.info(`» created issue #${result.data.number} (id ${result.data.id})`);
48
+
49
+ // record so a later comment on / label of THIS issue passes the scope guard.
50
+ recordCreatedTarget(ctx, result.data.number);
51
+
52
+ const nodeId = result.data.node_id;
53
+ if (typeof nodeId === "string" && nodeId.length > 0) {
54
+ await patchWorkflowRunFields(ctx, {
55
+ issueNodeId: nodeId,
56
+ });
57
+ }
58
+
59
+ return {
60
+ success: true,
61
+ issueId: result.data.id,
62
+ number: result.data.number,
63
+ url: result.data.html_url,
64
+ title: result.data.title,
65
+ state: result.data.state,
66
+ labels: result.data.labels?.map((label) =>
67
+ typeof label === "string" ? label : label.name,
68
+ ),
69
+ assignees: result.data.assignees?.map((assignee) => assignee.login),
70
+ };
71
+ }),
72
+ });
73
+ }