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
package/src/mcp/git.ts ADDED
@@ -0,0 +1,890 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { regex } from "arkregex";
5
+ import { type } from "arktype";
6
+ import {
7
+ assertNoBlockedDestroy,
8
+ assertNoSecretsInDiff,
9
+ enforceProtectedPaths,
10
+ enforceRemediationPaths,
11
+ } from "#app/mcp/guardrails";
12
+ import type { ToolContext } from "#app/mcp/server";
13
+ import { execute, tool } from "#app/mcp/shared";
14
+ import type { StoredPushDest } from "#app/toolState";
15
+ import { log } from "#app/utils/cli";
16
+ import { $git, $gitFetchWithDeepen } from "#app/utils/gitAuth";
17
+ import { executeLifecycleHook, type LifecycleHookFailure } from "#app/utils/lifecycle";
18
+ import { $ } from "#app/utils/shell";
19
+
20
+ type PushDestination = {
21
+ remoteName: string;
22
+ remoteBranch: string;
23
+ url: string;
24
+ };
25
+
26
+ /**
27
+ * get where git would actually push this branch.
28
+ * prefers the stored destination from toolState (set by checkout_pr) when it
29
+ * matches the current branch, because git config reads can silently fail in
30
+ * certain environments causing pushes to the wrong remote branch.
31
+ *
32
+ * falls back to reading branch.X.pushRemote and branch.X.merge from git config,
33
+ * and finally to origin/<branch> for branches created without checkout_pr.
34
+ */
35
+ function getPushDestination(
36
+ branch: string,
37
+ storedDest: StoredPushDest | undefined,
38
+ ): PushDestination {
39
+ // prefer stored destination from checkout_pr when it matches the current branch
40
+ if (storedDest && storedDest.localBranch === branch) {
41
+ log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
42
+ const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
43
+ log: false,
44
+ }).trim();
45
+ return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
46
+ }
47
+
48
+ // fall back to git config (for branches not created by checkout_pr)
49
+ try {
50
+ const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
51
+ const merge = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
52
+ const remoteBranch = merge.replace(/^refs\/heads\//, "");
53
+ const url = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
54
+ return { remoteName: pushRemote, remoteBranch, url };
55
+ } catch {
56
+ // no push config - branch was created locally without checkout_pr
57
+ log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
58
+ const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
59
+ return { remoteName: "origin", remoteBranch: branch, url };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * normalize URL for comparison (handle .git suffix, case)
65
+ */
66
+ function normalizeUrl(url: string): string {
67
+ return url.replace(/\.git$/, "").toLowerCase();
68
+ }
69
+
70
+ // SECURITY: reject refs/branch names that begin with "-". git's parseopt
71
+ // accepts options intermixed with positional args, so a ref like
72
+ // "--upload-pack=evil" could be interpreted as a flag rather than a refspec.
73
+ export function rejectIfLeadingDash(value: string, kind: string): void {
74
+ if (value.startsWith("-")) {
75
+ throw new Error(`Blocked: ${kind} '${value}' starts with '-' — git could parse it as a flag.`);
76
+ }
77
+ }
78
+
79
+ // SECURITY: branch inputs to push/delete must be bare branch names. a branch
80
+ // name like "refs/heads/main" bypasses the restricted-mode default-branch
81
+ // check below (which does exact-string compare against "main"), and symbolic
82
+ // refs (HEAD / FETCH_HEAD / ORIG_HEAD / MERGE_HEAD) would resolve to
83
+ // whatever commit those refs point at — both routes let an agent push to
84
+ // protected branches even under push: restricted. checkout_pr only ever
85
+ // stores bare names like "pr-123", so nothing legitimate relies on the
86
+ // refs/... form here.
87
+ const SYMBOLIC_REFS = new Set(["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD"]);
88
+ export function rejectSpecialRef(value: string, kind: string): void {
89
+ rejectIfLeadingDash(value, kind);
90
+ if (value.startsWith("refs/")) {
91
+ throw new Error(
92
+ `Blocked: ${kind} '${value}' is a fully-qualified ref path. Use a bare branch name (e.g. 'feature/foo' or 'main'), not a 'refs/heads/...' form.`,
93
+ );
94
+ }
95
+ if (SYMBOLIC_REFS.has(value)) {
96
+ throw new Error(
97
+ `Blocked: ${kind} '${value}' is a git symbolic ref, not a branch name. Pass the resolved branch name (e.g. 'main'), or omit branchName to push the current branch.`,
98
+ );
99
+ }
100
+ // SECURITY: git interprets ':' and leading '+' as refspec syntax, not as
101
+ // part of a branch name. without this check, an agent under push:restricted
102
+ // can smuggle a full refspec through branchName:
103
+ // - "evil:refs/heads/main" → pushes local 'evil' to remote main
104
+ // - ":refs/heads/main" → deletes remote main
105
+ // - ":other" → deletes remote 'other' under push:restricted
106
+ // - "+main" → force-push refspec
107
+ // the default-branch guard downstream is an exact-string compare, so any
108
+ // character that lets git parse the value as <src>:<dst> (or as a force
109
+ // prefix) bypasses it. git's own check-ref-format forbids ':', '+', '^',
110
+ // '~', '?', '*', '[', '\\', and whitespace in branch names, so rejecting
111
+ // them here cannot false-positive against a legitimate branch name.
112
+ const BAD = /[:+^~?*[\\\s]/;
113
+ const badMatch = value.match(BAD);
114
+ if (badMatch) {
115
+ throw new Error(
116
+ `Blocked: ${kind} '${value}' contains '${badMatch[0]}', which git interprets as refspec/revision syntax, not as part of a branch name.`,
117
+ );
118
+ }
119
+ }
120
+
121
+ // SECURITY: validate tag names so the push_tags refspec can't be split into
122
+ // a <src>:<dst> refspec that targets a non-tag ref. without this, a tag like
123
+ // "foo:refs/heads/main" becomes "refs/tags/foo:refs/heads/main" and git
124
+ // pushes the local tag's commit to remote main — a back door around the
125
+ // branch-push rules in push_branch. keep the allow-list conservative (git's
126
+ // own check-ref-format forbids far more, but we only need enough to block
127
+ // refspec injection).
128
+ export function validateTagName(tag: string): void {
129
+ rejectIfLeadingDash(tag, "tag");
130
+ if (!/^[A-Za-z0-9._/-]+$/.test(tag)) {
131
+ throw new Error(
132
+ `Blocked: tag '${tag}' contains characters that could be parsed as a refspec or flag. Tags must match [A-Za-z0-9._/-]+.`,
133
+ );
134
+ }
135
+ }
136
+
137
+ /**
138
+ * validate that the push destination matches expected URL.
139
+ * pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
140
+ */
141
+ function validatePushDestination(ctx: ToolContext, branch: string): PushDestination {
142
+ const pushUrl = ctx.toolState.pushUrl;
143
+ if (!pushUrl) throw new Error("pushUrl not set - setupGit must run before push_branch");
144
+
145
+ const dest = getPushDestination(branch, ctx.toolState.pushDest);
146
+
147
+ if (normalizeUrl(dest.url) !== normalizeUrl(pushUrl)) {
148
+ throw new Error(
149
+ `Push blocked: destination does not match expected repository.\n` +
150
+ `Expected: ${pushUrl}\n` +
151
+ `Actual: ${dest.url}\n` +
152
+ `Git configuration may have been tampered with.`,
153
+ );
154
+ }
155
+
156
+ return dest;
157
+ }
158
+
159
+ export const PushBranch = type({
160
+ branchName: type.string
161
+ .describe("The branch name to push (defaults to current branch)")
162
+ .optional(),
163
+ force: type.boolean.describe("Force push (use with caution)").default(false),
164
+ });
165
+
166
+ // classify an error from `$git("push", ...)` to decide retry vs. recovery
167
+ // vs. rethrow. exported for tests.
168
+ //
169
+ // - `concurrent-push`: server-side compare-and-swap failed because the ref
170
+ // advanced between fetch and push. recovery is fetch + integrate + retry.
171
+ // matches both the client-side detection (`fetch first` /
172
+ // `non-fast-forward`) and the server-side detection (`cannot lock ref`
173
+ // with `is at <SHA1> but expected <SHA2>`).
174
+ // - `transient`: network or upstream server hiccup (RPC failed mid-stream,
175
+ // HTTP 5xx, early EOF, reset, timeout, dns flake). push is idempotent so
176
+ // verbatim retry with backoff is safe.
177
+ // - `unknown`: anything else (including auth/permission/protected-branch
178
+ // rejections). retrying these wastes time; surface to the caller.
179
+ //
180
+ // kept conservative: a misclassification of `unknown` -> `transient` would
181
+ // cause two extra round-trips on a permanently-failing push, while the
182
+ // reverse (true transient labeled `unknown`) just falls back to current
183
+ // behavior. so we only mark as transient when the error string is
184
+ // unambiguously a network/server-side fault, not a refusal.
185
+ export type PushErrorKind = "concurrent-push" | "transient" | "unknown";
186
+
187
+ const CONCURRENT_PUSH_PATTERNS = ["fetch first", "non-fast-forward", "cannot lock ref"] as const;
188
+
189
+ const TRANSIENT_PATTERNS: RegExp[] = [
190
+ /RPC failed/i,
191
+ /early EOF/,
192
+ /the remote end hung up unexpectedly/,
193
+ /Connection reset/i,
194
+ /Could not resolve host/i,
195
+ /Operation timed out/i,
196
+ /HTTP\/2 stream \d+ was not closed cleanly/i,
197
+ /unexpected disconnect while reading sideband packet/i,
198
+ // libcurl HTTP 5xx surfaced by git over https. matches both the
199
+ // libcurl-style "The requested URL returned error: 502" and the more
200
+ // recent "HTTP 502" wording. most 4xx is intentionally excluded —
201
+ // 401/403/404 indicate auth/permission problems that are not
202
+ // retry-safe — but 429 (rate-limited / abuse detection) IS retry-safe
203
+ // and GitHub occasionally surfaces it on git push, so it's included
204
+ // explicitly below.
205
+ /HTTP 5\d\d/,
206
+ /returned error: 5\d\d/i,
207
+ /HTTP 429/,
208
+ /returned error: 429/i,
209
+ // github installation tokens can 401 for seconds after minting while
210
+ // replicating (@octokit/auth-app retries the same class). git push
211
+ // surfaces it as "Invalid username or token", distinct from 403
212
+ // permission denied — safe to backoff-retry with the same token.
213
+ /Invalid username or token/,
214
+ /Authentication failed for 'https:\/\/github\.com\//,
215
+ ];
216
+
217
+ export function classifyPushError(msg: string): PushErrorKind {
218
+ if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p))) return "concurrent-push";
219
+ if (TRANSIENT_PATTERNS.some((p) => p.test(msg))) return "transient";
220
+ return "unknown";
221
+ }
222
+
223
+ // exponential backoff delays before retry attempts 2-6. attempt 1 is the
224
+ // original push. total worst-case added latency: ~60s. larger than it looks
225
+ // like it needs to be, on purpose: github installation-token replication lag
226
+ // can exceed 20s, and the same token surfaces as "Invalid username or token"
227
+ // until it propagates to the push edge. re-minting does not help (a fresh
228
+ // token has the same lag), so the cure is to wait out the propagation with
229
+ // the same token. a short window reddens CI (notably the push-restricted
230
+ // e2e); ~60s rides it out while still bounding a permanently-failing push.
231
+ const TRANSIENT_RETRY_DELAYS_MS = [2000, 4000, 8000, 16000, 30000];
232
+
233
+ /**
234
+ * push with backoff retry on transient failures (network 5xx, connection
235
+ * reset, and the freshly-minted-token 401 github surfaces as "Invalid
236
+ * username or token" while the installation token replicates across edges —
237
+ * see TRANSIENT_PATTERNS). concurrent-push and permission rejections are not
238
+ * retried — they need caller intervention.
239
+ *
240
+ * shared by push_branch, push_tags, and delete_branch so all three are
241
+ * equally resilient to github's post-mint replication lag. before this,
242
+ * only push_branch retried, so a tag push or branch delete that happened to
243
+ * hit an un-replicated edge failed outright even though the token was valid.
244
+ */
245
+ async function pushWithRetry(args: string[], token: string, disableHooks: boolean): Promise<void> {
246
+ let lastErr: unknown;
247
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
248
+ try {
249
+ await $git("push", args, { token, disableHooks });
250
+ if (attempt > 0) log.info(`push succeeded on attempt ${attempt + 1}`);
251
+ return;
252
+ } catch (err) {
253
+ lastErr = err;
254
+ const msg = err instanceof Error ? err.message : String(err);
255
+ if (classifyPushError(msg) === "transient" && attempt < TRANSIENT_RETRY_DELAYS_MS.length) {
256
+ // jitter avoids lockstep retries when several agents are hit by the
257
+ // same upstream blip simultaneously.
258
+ const baseDelay = TRANSIENT_RETRY_DELAYS_MS[attempt] ?? 5000;
259
+ const delay = Math.round(baseDelay * (0.75 + Math.random() * 0.5));
260
+ log.info(
261
+ `push attempt ${attempt + 1} failed (transient), retrying in ${delay}ms: ${msg.slice(0, 300)}`,
262
+ );
263
+ await new Promise((r) => setTimeout(r, delay));
264
+ continue;
265
+ }
266
+ throw err;
267
+ }
268
+ }
269
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
270
+ }
271
+
272
+ export function PushBranchTool(ctx: ToolContext) {
273
+ const defaultBranch = ctx.repo.data.default_branch || "main";
274
+ const pushPermission = ctx.payload.push;
275
+
276
+ return tool({
277
+ name: "push_branch",
278
+ description:
279
+ "Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
280
+ 'Example: `push_branch({})` to push the current branch. Example: `push_branch({ branchName: "pr-1" })` to push a specific local branch. ' +
281
+ "If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
282
+ "The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
283
+ "Requires a clean working tree. Runs the repository prepush hook (if configured) — best-effort. If the hook fails, the tool returns the failure output and every subsequent call this run skips the hook. " +
284
+ "Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode. " +
285
+ "If the response reports a timeout, the underlying push may have actually succeeded — verify with `git log origin/<branch>` (or this tool with command 'log') before retrying, otherwise you'll push a duplicate.",
286
+ parameters: PushBranch,
287
+ execute: execute(async ({ branchName, force }) => {
288
+ // permission check
289
+ if (pushPermission === "disabled") {
290
+ throw new Error("Push is disabled. This repository is configured for read-only access.");
291
+ }
292
+
293
+ const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
294
+ // check the resolved branch too — rev-parse could surface a weird current
295
+ // branch name that would otherwise bypass the user-facing check. use
296
+ // rejectSpecialRef so "refs/heads/main" and symbolic refs like HEAD
297
+ // can't slip past the default-branch guard below.
298
+ rejectSpecialRef(branch, "branch");
299
+
300
+ // reject push if working tree is dirty — forces agent to commit or discard before pushing
301
+ const status = $("git", ["status", "--porcelain"], { log: false });
302
+ if (status) {
303
+ throw new Error(
304
+ `push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.\n\n` +
305
+ `git status:\n${status}` +
306
+ (ctx.toolState.prepushFailureCount > 0
307
+ ? "\n\nnote: the prepush hook failed earlier this run — once the working tree is clean, push_branch will skip the hook."
308
+ : ""),
309
+ );
310
+ }
311
+
312
+ // Remediate-mode guardrail: refuse to push if the run touched files
313
+ // outside the Terraform allow-list. No-op in every other mode.
314
+ enforceRemediationPaths(ctx);
315
+
316
+ // Remediate-mode guardrail: refuse to push if terraform_plan showed the
317
+ // change would destroy/replace a stateful (data-bearing) resource, unless
318
+ // the operator allowed it via `allow_replace`. No-op when no plan ran.
319
+ assertNoBlockedDestroy(ctx);
320
+
321
+ // §2.7 — refuse to push if the run touched a path the operator marked
322
+ // never-auto-modify via `protected_paths`. No-op when unset.
323
+ enforceProtectedPaths(ctx);
324
+
325
+ // §2.8 — refuse to push if the diff inlines a literal secret (a fix must
326
+ // reference a variable/secret store, never paste the value).
327
+ assertNoSecretsInDiff(ctx);
328
+
329
+ // validate push destination matches expected URL
330
+ const pushDest = validatePushDestination(ctx, branch);
331
+
332
+ // backstop against subagent-induced cross-PR clobbers: a subagent
333
+ // shares cwd + toolState with the orchestrator, so its `checkout_pr(N)`
334
+ // moves HEAD to pr-N and persists pushDest pointing at the foreign
335
+ // PR's remote branch. refuse pr-N → origin/<other> pushes unless this
336
+ // run is itself scoped to PR N (zed-industries/cloud, 2026-05-18).
337
+ const prBranchMatch = branch.match(/^pr-(\d+)$/);
338
+ if (prBranchMatch && pushDest.remoteBranch !== branch) {
339
+ const prNumber = Number(prBranchMatch[1]);
340
+ const event = ctx.payload.event;
341
+ const runScoped = event.is_pr === true && event.issue_number === prNumber;
342
+ if (!runScoped) {
343
+ throw new Error(
344
+ `push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', ` +
345
+ `but this run is not scoped to PR #${prNumber}. ` +
346
+ `the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent — subagents share the working tree and toolState with the orchestrator). ` +
347
+ `you have probably landed your commit on the wrong branch. ` +
348
+ `switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. ` +
349
+ `if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`,
350
+ );
351
+ }
352
+ }
353
+
354
+ // block pushes to default branch in restricted mode
355
+ if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
356
+ throw new Error(
357
+ `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. ` +
358
+ `Create a feature branch and open a PR instead.`,
359
+ );
360
+ }
361
+
362
+ // use refspec when local and remote branch names differ
363
+ const refspec =
364
+ branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
365
+ const pushArgs = force
366
+ ? ["--force", "-u", pushDest.remoteName, refspec]
367
+ : ["-u", pushDest.remoteName, refspec];
368
+
369
+ const prepushSkipped = ctx.toolState.prepushFailureCount > 0;
370
+ if (prepushSkipped) {
371
+ log.info(`» skipping prepush hook (failed earlier this run)`);
372
+ } else if (ctx.prepushScript) {
373
+ const prepushHook = await executeLifecycleHook({
374
+ event: "prepush",
375
+ script: ctx.prepushScript,
376
+ });
377
+ if (prepushHook.failure) {
378
+ ctx.toolState.prepushFailureCount += 1;
379
+ throw new Error(buildPrepushFailureMessage(prepushHook.failure, ctx.payload.shell));
380
+ }
381
+
382
+ // re-verify clean working tree after prepush. a hook that writes tracked
383
+ // files (formatter, type generator, build artifacts) would leave those
384
+ // changes uncommitted — pushing now would silently drop them, and the
385
+ // agent would report a "successful push" of code the hook had expected
386
+ // to be included.
387
+ const postHookStatus = $("git", ["status", "--porcelain"], { log: false });
388
+ if (postHookStatus) {
389
+ throw new Error(
390
+ `push blocked: the prepush hook modified the working tree. those changes are not included in the push. commit or discard them (or change the hook to not mutate tracked files) before retrying.\n\n` +
391
+ `git status:\n${postHookStatus}`,
392
+ );
393
+ }
394
+ }
395
+
396
+ log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
397
+ if (force) {
398
+ log.warning(`force pushing - this will overwrite remote history`);
399
+ }
400
+
401
+ // push is idempotent, so pushWithRetry rides out transient failures
402
+ // (5xx, reset, freshly-minted-token 401). concurrent-push is not
403
+ // transient — it surfaces here so we can render the integrate-and-retry
404
+ // recovery the agent needs.
405
+ try {
406
+ await pushWithRetry(pushArgs, ctx.gitToken, ctx.payload.shell !== "enabled");
407
+ } catch (err) {
408
+ const msg = err instanceof Error ? err.message : String(err);
409
+ if (classifyPushError(msg) === "concurrent-push") {
410
+ // git rebase is blocked through the MCP tool when shell is disabled
411
+ // (rebase --exec can execute arbitrary code). merge always works and
412
+ // integrates remote changes cleanly, so suggest it as the default.
413
+ const integrateStep =
414
+ ctx.payload.shell === "disabled"
415
+ ? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
416
+ : `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
417
+ throw new Error(
418
+ `push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally (often a concurrent push to the same branch).\n\n` +
419
+ `to resolve this:\n` +
420
+ `1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
421
+ `${integrateStep}\n` +
422
+ `3. resolve any merge conflicts if needed\n` +
423
+ `4. retry push_branch`,
424
+ );
425
+ }
426
+ throw err;
427
+ }
428
+
429
+ const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
430
+ log.info(
431
+ `» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`,
432
+ );
433
+
434
+ const baseMsg = `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`;
435
+ const message = prepushSkipped
436
+ ? `${baseMsg} (prepush hook skipped — failed earlier this run).`
437
+ : baseMsg;
438
+
439
+ return {
440
+ success: true,
441
+ branch,
442
+ remoteBranch: pushDest.remoteBranch,
443
+ remote: pushDest.remoteName,
444
+ force,
445
+ prepushSkipped,
446
+ message,
447
+ };
448
+ }),
449
+ });
450
+ }
451
+
452
+ /** agent-facing prepush failure message: script output + bypass guidance,
453
+ * with no generic lifecycle retry advice (which would conflict). */
454
+ function buildPrepushFailureMessage(
455
+ failure: LifecycleHookFailure,
456
+ shell: ToolContext["payload"]["shell"],
457
+ ): string {
458
+ const header =
459
+ failure.kind === "exit"
460
+ ? `prepush hook failed with exit code ${failure.exitCode}.\n\nscript output:\n${failure.output || "(empty)"}`
461
+ : failure.kind === "timeout"
462
+ ? `prepush hook timed out — the script is hung or doing too much work.`
463
+ : `prepush hook failed to spawn: ${failure.spawnError}.`;
464
+
465
+ const ifRealBug =
466
+ shell === "disabled"
467
+ ? `fix it before pushing again — shell access is disabled in this run, so you can't re-run the hook command yourself.`
468
+ : `run the hook command yourself via the shell tool to iterate (push_branch will NOT re-run it).`;
469
+
470
+ return (
471
+ `${header}\n\n` +
472
+ `this repo's prepush hook is best-effort: the next push_branch call will SKIP the hook and proceed. ` +
473
+ `if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call push_branch again. ` +
474
+ `if it could be a real bug in your code, ${ifRealBug}`
475
+ );
476
+ }
477
+
478
+ // commands that require authentication - redirect to dedicated tools.
479
+ // exported so tests can exercise the same table the runtime uses.
480
+ //
481
+ // note: the `pull` redirect intentionally does not mention `rebase` — under
482
+ // shell=disabled rebase is itself blocked by NOSHELL_BLOCKED_SUBCOMMANDS, so
483
+ // advertising it here would just send the agent into a second block. agents
484
+ // under shell=restricted/enabled who prefer rebase can invoke it directly;
485
+ // the redirect's job is to name the canonical alternative (merge), which
486
+ // works in all modes.
487
+ export const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
488
+ push: "use the push_branch tool instead — it handles authentication and permission checks.",
489
+ fetch: "use the git_fetch tool instead — it handles authentication.",
490
+ pull: "use git_fetch to fetch the remote ref, then call this git tool with command 'merge' locally.",
491
+ clone: "the repository is already cloned. use checkout_pr for PR branches.",
492
+ };
493
+
494
+ // SECURITY: subcommands blocked when shell is disabled.
495
+ // in disabled mode the agent has no shell access, so these subcommands are the
496
+ // primary escape vectors for arbitrary code execution. in restricted mode the
497
+ // agent already has shell in a stripped sandbox, so blocking these is redundant.
498
+ // exported so tests stay in sync with the runtime table.
499
+ export const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
500
+ config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
501
+ submodule:
502
+ "Blocked: git submodule can reference malicious repositories and execute code on update.",
503
+ "update-index":
504
+ "Blocked: git update-index can modify index entries in ways that bypass file protections.",
505
+ "filter-branch": "Blocked: git filter-branch executes arbitrary code on repository history.",
506
+ replace: "Blocked: git replace can redirect object lookups.",
507
+ // subcommands that accept --exec or similar flags for arbitrary code execution
508
+ rebase:
509
+ "Blocked: git rebase --exec can execute arbitrary shell commands. Use 'merge' instead to integrate remote changes.",
510
+ bisect:
511
+ "Blocked: git bisect run can execute arbitrary shell commands. Bisect by hand (bisect start/good/bad/reset) is not available through this tool either — ask the user to run the bisect if needed.",
512
+ // difftool/mergetool exist to shell out to external diff/merge programs.
513
+ // both accept `--extcmd` / `-x` (difftool) or configured tool commands
514
+ // (mergetool) that run arbitrary code. NOSHELL_BLOCKED_ARGS catches the
515
+ // long `--extcmd` form, but not the `-x` short form — and globally blocking
516
+ // `-x` would false-positive on `git cherry-pick -x`. block the subcommands
517
+ // wholesale instead; neither has a meaningful use in an automated agent
518
+ // workflow (agents use `git diff` / `git show` for diffs and resolve
519
+ // conflicts via file edits, not a TUI merge tool).
520
+ difftool:
521
+ "Blocked: git difftool runs an external diff program via --extcmd/-x or configured tool and can execute arbitrary shell commands. Use 'diff' (or 'show' for single commits) to inspect changes — those output directly and don't invoke an external tool.",
522
+ mergetool:
523
+ "Blocked: git mergetool runs an external merge program configured via mergetool.<name>.cmd and can execute arbitrary shell commands. Resolve conflicts by editing the files directly (conflict markers are written into the working tree) and then commit.",
524
+ };
525
+
526
+ // SECURITY: subcommand-specific arg flags that execute code.
527
+ // only blocked when shell is disabled — in restricted mode the agent already
528
+ // has shell access in a stripped sandbox, so these provide no additional security.
529
+ //
530
+ // NOTE: global git flags like -c and --config-env are NOT included here
531
+ // because they only work before the subcommand. in the MCP tool, the
532
+ // subcommand is always first, so -c in args is parsed as a subcommand flag
533
+ // (e.g., git log -c = combined diff format), not config injection.
534
+ // the subcommand check (rejecting "-" prefix) already blocks that attack.
535
+ //
536
+ // matched as: arg === flag OR arg starts with flag + "="
537
+ // (avoids false positives like --exclude matching --exec).
538
+ // exported so tests stay in sync with the runtime flag set.
539
+ export const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
540
+
541
+ const COLLAPSE_THRESHOLD = 200;
542
+
543
+ /** above this, the full body is spilled to a tmp file and only a short head
544
+ * preview is logged + returned inline. mirrors `capOutput` in `mcp/shell.ts`
545
+ * (which uses 5000 for shell commands; diff/log outputs need more headroom).
546
+ * the operator log gets a single summary line instead of a 1000+ line dump. */
547
+ const MAX_GIT_OUTPUT_CHARS = 50_000;
548
+ const OVERFLOW_PREVIEW_LINES = 50;
549
+ /** absolute char cap on the inline preview, in case the first
550
+ * `OVERFLOW_PREVIEW_LINES` lines contain a minified blob / binary diff /
551
+ * single very long line that would blow the agent's context anyway. */
552
+ const OVERFLOW_PREVIEW_MAX_CHARS = 5_000;
553
+
554
+ /** detect refs in `git diff` args that would produce a symmetric (two-dot)
555
+ * diff including the inverse of commits that landed on `<ref>` since the
556
+ * branch forked. returns the offending arg + the ref that's ahead + count of
557
+ * unmerged commits, or null if the call is safe. silently ignores args that
558
+ * aren't refs (paths, pathspecs), three-dot ranges (those are merge-base
559
+ * diffs, the correct shape), and any call passing `--merge-base` (git's own
560
+ * shorthand for a merge-base diff, also safe). see [run 26545933188](https://github.com/terramend/app/actions/runs/26545933188)
561
+ * for the failure mode this guards against. */
562
+ function detectSymmetricDiffTrap(
563
+ args: string[],
564
+ ): { arg: string; aheadRef: string; ahead: number } | null {
565
+ // git's own `--merge-base` flag (2.30+) produces a safe merge-base diff
566
+ // regardless of the positional ref; the GHA runner has git 2.54.x.
567
+ if (args.includes("--merge-base")) return null;
568
+ // ignore everything after `--` (pathspec separator)
569
+ const endIdx = args.indexOf("--");
570
+ const positionals = (endIdx === -1 ? args : args.slice(0, endIdx)).filter(
571
+ (a) => !a.startsWith("-"),
572
+ );
573
+ for (const p of positionals) {
574
+ if (p.includes("...")) continue; // three-dot = merge-base diff, safe
575
+ // bare ref `A`: implicit second side is HEAD; agent's intent is
576
+ // "what my branch changed vs <ref>". fires when <ref> has commits HEAD
577
+ // doesn't (branch behind base). diffs against an ancestor (HEAD ahead
578
+ // of <ref>) are the legitimate "what did I add since X" case and must
579
+ // not be blocked.
580
+ //
581
+ // two-dot range `A..B`: degenerate when one side is an ancestor of the
582
+ // other (the tree diff equals the merge-base diff — safe). only the
583
+ // truly-diverged case (BOTH sides have commits the other lacks) pulls
584
+ // unwanted inverse-of-progress into the diff. shorthand expansions:
585
+ // `A..` → `A..HEAD`, `..A` → `HEAD..A`.
586
+ if (p.includes("..")) {
587
+ const parts = p.split("..");
588
+ if (parts.length !== 2) continue;
589
+ const left = parts[0] || "HEAD";
590
+ const right = parts[1] || "HEAD";
591
+ const leftAhead = countAhead(right, left);
592
+ const rightAhead = countAhead(left, right);
593
+ if (leftAhead === null || rightAhead === null) continue;
594
+ if (leftAhead > 0 && rightAhead > 0) {
595
+ const aheadRef = leftAhead >= rightAhead ? left : right;
596
+ return { arg: p, aheadRef, ahead: Math.max(leftAhead, rightAhead) };
597
+ }
598
+ continue;
599
+ }
600
+ const ahead = countAhead("HEAD", p);
601
+ if (ahead === null) continue;
602
+ if (ahead > 0) return { arg: p, aheadRef: p, ahead };
603
+ }
604
+ return null;
605
+ }
606
+
607
+ /** `rev-list --count head..base` = commits on `base` not on `head`. returns
608
+ * null if either ref is unresolvable (probably a pathspec). */
609
+ function countAhead(head: string, base: string): number | null {
610
+ try {
611
+ const out = $("git", ["rev-list", "--count", `${head}..${base}`], { log: false }).trim();
612
+ const n = parseInt(out, 10);
613
+ return Number.isFinite(n) ? n : null;
614
+ } catch {
615
+ return null;
616
+ }
617
+ }
618
+
619
+ /** persist `output` to a tmp file and return an agent-facing string that
620
+ * leads with a head preview and ends with a sentinel pointing at the path.
621
+ * keeps the operator log to a single summary line for the overflow case. */
622
+ function spillGitOutput(params: {
623
+ command: string;
624
+ args: string[];
625
+ output: string;
626
+ lineCount: number;
627
+ }): { output: string; outputPath: string } {
628
+ const tempDir = process.env.TERRAMEND_TEMP_DIR;
629
+ if (!tempDir) throw new Error("TERRAMEND_TEMP_DIR not set");
630
+ const outputPath = join(tempDir, `git-${params.command}-${randomUUID().slice(0, 8)}.txt`);
631
+ writeFileSync(outputPath, params.output);
632
+ const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
633
+ const preview =
634
+ previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS
635
+ ? previewByLines
636
+ : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}…`;
637
+ log.info(
638
+ `» git ${params.command} ${params.args.join(" ")}: ${params.lineCount} lines / ${params.output.length} chars → ${outputPath}`,
639
+ );
640
+ return {
641
+ output: `${preview}\n\n... [output truncated; full ${params.lineCount}-line / ${params.output.length}-char body saved to ${outputPath} — read selectively with \`read({ filePath: "${outputPath}" })\`] ...`,
642
+ outputPath,
643
+ };
644
+ }
645
+
646
+ // SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
647
+ // this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
648
+ //
649
+ // critical attack: git -c "alias.x=!evil-command" x
650
+ // -> sets alias "x" to a shell command via -c config injection, then runs it
651
+ // -> achieves arbitrary code execution even with shell=disabled
652
+ const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
653
+
654
+ const Git = type({
655
+ command: type(subcommandPattern).describe("Git command (e.g., 'status', 'log', 'diff')"),
656
+ args: type.string.array().describe("Additional arguments for the git command").optional(),
657
+ });
658
+
659
+ export function GitTool(ctx: ToolContext) {
660
+ return tool({
661
+ name: "git",
662
+ description:
663
+ "Run a git subcommand. `command` is the subcommand ONLY — never repeat it inside `args`. " +
664
+ "`args` is optional; omit it entirely for no-flag invocations like plain `git status`. " +
665
+ 'Example: `git({ command: "status" })` for plain `git status`. ' +
666
+ 'Example: `git({ command: "log", args: ["--oneline", "-n", "20"] })`. ' +
667
+ 'Example: `git({ command: "diff", args: ["--merge-base", "origin/main"] })` — merge-base diff including uncommitted edits (single MCP call). ' +
668
+ 'Example: `git({ command: "diff", args: ["origin/main...HEAD"] })` — three-dot, committed-only changes vs merge-base. ' +
669
+ "For PR-scope diffs ALWAYS use `--merge-base <base>` or three-dot `<base>...HEAD`. " +
670
+ "Bare `<base>` and two-dot `<base>..HEAD` are symmetric (working-tree-or-HEAD vs ref): when your branch is behind `<base>` they include the inverse of every commit on `<base>` you lack — pure noise, and this tool will reject those forms when the divergence is detected. " +
671
+ "Output >50K chars is spilled to a tmp file; the tool returns a head preview + path you can `read` selectively. " +
672
+ "For push/fetch, use the dedicated MCP tools (push_branch, git_fetch). " +
673
+ "git pull is not available — use git_fetch then this tool with command 'merge'.",
674
+ parameters: Git,
675
+ execute: execute(async (params) => {
676
+ const command = params.command;
677
+ const args = params.args ?? [];
678
+
679
+ // guard: {command:"status",args:["status"]} → `git status status`, where
680
+ // git silently treats args[0] as a pathspec. when nothing matches the
681
+ // path, status prints "nothing to commit, working tree clean" even on a
682
+ // dirty tree — a real model failure mode that burned a ~$3 run before
683
+ // self-correction. generalises to every subcommand (`diff diff`,
684
+ // `log log`, etc.).
685
+ if (args[0]?.toLowerCase() === command.toLowerCase()) {
686
+ throw new Error(
687
+ `git ${command}: '${args[0]}' duplicates the subcommand — drop args[0] ` +
688
+ `(the subcommand only belongs in 'command'). git would otherwise parse it as ` +
689
+ `a pathspec and silently return empty/clean output when nothing matches. ` +
690
+ `if you really meant a pathspec named '${args[0]}', use args: ["--", "${args[0]}"].`,
691
+ );
692
+ }
693
+
694
+ const redirect = AUTH_REQUIRED_REDIRECT[command];
695
+ if (redirect) {
696
+ throw new Error(`git ${command} is not available through this tool — ${redirect}`);
697
+ }
698
+
699
+ // SECURITY: block dangerous subcommands when shell is disabled.
700
+ // in restricted mode the agent has shell in a stripped sandbox, so blocking
701
+ // these through the MCP tool is redundant (agent can do it via shell).
702
+ if (ctx.payload.shell === "disabled") {
703
+ const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[command];
704
+ if (blocked) {
705
+ throw new Error(blocked);
706
+ }
707
+
708
+ // block subcommand-specific flags that execute arbitrary code
709
+ for (const arg of args) {
710
+ const isBlocked = NOSHELL_BLOCKED_ARGS.some(
711
+ (flag) => arg === flag || arg.startsWith(`${flag}=`),
712
+ );
713
+ if (isBlocked) {
714
+ throw new Error(
715
+ `Blocked: '${arg}' flag can execute arbitrary code and is not allowed.`,
716
+ );
717
+ }
718
+ }
719
+ }
720
+
721
+ // reject symmetric (two-dot or bare-ref) diffs whose endpoints have
722
+ // commits each other doesn't — those include the *inverse* of every
723
+ // commit on the diverged side, ballooning the diff and confusing
724
+ // reviewer subagents. three-dot (`A...B`) and `--merge-base` are
725
+ // always allowed (both produce merge-base diffs).
726
+ if (command === "diff") {
727
+ const trap = detectSymmetricDiffTrap(args);
728
+ if (trap) {
729
+ throw new Error(
730
+ `git diff '${trap.arg}' would include the inverse of ${trap.ahead} commit(s) on '${trap.aheadRef}' that aren't on the other side — that's a symmetric tree diff full of upstream noise, not your branch's own changes.\n\n` +
731
+ `use one of:\n` +
732
+ ` - git diff --merge-base ${trap.aheadRef} (one MCP call; merge-base diff, includes uncommitted edits)\n` +
733
+ ` - git diff ${trap.aheadRef}...HEAD (three-dot; merge-base diff of committed-only changes)\n\n` +
734
+ `if you ALSO need the PR's pre-formatted diff, the orchestrator's checkout_pr response includes a \`diffPath\` you can \`read\` directly without invoking git at all.`,
735
+ );
736
+ }
737
+ }
738
+
739
+ // `git merge-base --is-ancestor` uses exit codes as data: 0 = ancestor,
740
+ // 1 = not-an-ancestor, >1 = real error. Surface the binary answer
741
+ // instead of throwing on exit 1. see #766.
742
+ if (command === "merge-base" && args.includes("--is-ancestor")) {
743
+ let isAncestor = true;
744
+ $("git", [command, ...args], {
745
+ log: false,
746
+ onError: (r) => {
747
+ if (r.status === 1) {
748
+ isAncestor = false;
749
+ return;
750
+ }
751
+ const detail = [r.stderr, r.stdout]
752
+ .map((s) => s.trim())
753
+ .filter(Boolean)
754
+ .join("\n");
755
+ throw new Error(
756
+ `git merge-base --is-ancestor failed (exit ${r.status}): ${detail || "Unknown error"}`,
757
+ );
758
+ },
759
+ });
760
+ return { success: true, isAncestor };
761
+ }
762
+
763
+ const output = $("git", [command, ...args], { log: false });
764
+ const lineCount = output.split("\n").length;
765
+ if (output.length > MAX_GIT_OUTPUT_CHARS) {
766
+ const spilled = spillGitOutput({ command, args, output, lineCount });
767
+ return { success: true, output: spilled.output, outputPath: spilled.outputPath };
768
+ }
769
+ if (lineCount > COLLAPSE_THRESHOLD) {
770
+ log.group(`git ${command} output (${lineCount} lines)`, () => {
771
+ log.info(output);
772
+ });
773
+ } else if (output) {
774
+ log.info(output);
775
+ }
776
+
777
+ return { success: true, output };
778
+ }),
779
+ });
780
+ }
781
+
782
+ const GitFetch = type({
783
+ ref: type.string.describe("Ref to fetch: branch name, tag, or 'pull/N/head' for PRs"),
784
+ depth: type.number.describe("Fetch depth (for shallow clones)").optional(),
785
+ });
786
+
787
+ export function GitFetchTool(ctx: ToolContext) {
788
+ return tool({
789
+ name: "git_fetch",
790
+ description:
791
+ "Fetch refs from remote repository. Use this instead of git fetch directly. " +
792
+ 'Example: `git_fetch({ ref: "main" })`. With depth: `git_fetch({ ref: "pull/1234/head", depth: 1 })`.',
793
+ parameters: GitFetch,
794
+ execute: execute(async (params) => {
795
+ rejectIfLeadingDash(params.ref, "ref");
796
+ const fetchArgs = ["--no-tags", "origin", params.ref];
797
+ if (params.depth !== undefined) {
798
+ fetchArgs.push(`--depth=${params.depth}`);
799
+ }
800
+ await $gitFetchWithDeepen(fetchArgs, { token: ctx.gitToken }, "git_fetch");
801
+ return { success: true, ref: params.ref };
802
+ }),
803
+ });
804
+ }
805
+
806
+ const DeleteBranch = type({
807
+ branchName: type.string.describe("Remote branch to delete"),
808
+ });
809
+
810
+ export function DeleteBranchTool(ctx: ToolContext) {
811
+ const pushPermission = ctx.payload.push;
812
+ const defaultBranch = ctx.repo.data.default_branch || "main";
813
+
814
+ return tool({
815
+ name: "delete_branch",
816
+ description:
817
+ "Delete a remote branch. Requires push: enabled permission. " +
818
+ "Deletion of the repository's default branch is always blocked regardless of permission mode.",
819
+ parameters: DeleteBranch,
820
+ execute: execute(async (params) => {
821
+ if (pushPermission !== "enabled") {
822
+ throw new Error(
823
+ "Branch deletion requires push: enabled permission. " +
824
+ "Current mode only allows pushing to non-protected branches.",
825
+ );
826
+ }
827
+
828
+ // delete_branch is already gated on push: enabled, but also block the
829
+ // refs/heads/... and symbolic-ref forms so this tool can't be tricked
830
+ // into deleting a protected ref that wouldn't match a bare-name check.
831
+ rejectSpecialRef(params.branchName, "branchName");
832
+
833
+ // defense-in-depth: deleting the default branch is catastrophic and
834
+ // unlike pushing to main it has no easy revert path (GitHub retains
835
+ // refs for 30 days but restoring requires the reflog or a direct SHA).
836
+ // push: enabled authorizes pushes, not wholesale removal of the
837
+ // repository's primary branch. block it locally even if GitHub branch
838
+ // protection would also reject — some repos disable protection on
839
+ // default branches and we should not rely on that config for safety.
840
+ if (params.branchName === defaultBranch) {
841
+ throw new Error(
842
+ `Blocked: cannot delete the default branch '${defaultBranch}'. ` +
843
+ `If you really need to delete or rename it, do it manually via the repository settings.`,
844
+ );
845
+ }
846
+
847
+ // use refs/heads/<name> explicitly so a same-named tag can't be deleted
848
+ // by accident. `push --delete <bare-name>` resolves against both remote
849
+ // branches and tags; a tag-only match would silently remove the tag.
850
+ // rejectSpecialRef guarantees branchName is a bare name, so the
851
+ // branchName construction here can't collide with user-supplied refs.
852
+ await pushWithRetry(
853
+ ["origin", "--delete", `refs/heads/${params.branchName}`],
854
+ ctx.gitToken,
855
+ ctx.payload.shell !== "enabled",
856
+ );
857
+ log.info(`» deleted branch ${params.branchName}`);
858
+ return { success: true, deleted: params.branchName };
859
+ }),
860
+ });
861
+ }
862
+
863
+ const PushTags = type({
864
+ tag: type.string.describe("Tag name to push"),
865
+ force: type.boolean.describe("Force push the tag").default(false),
866
+ });
867
+
868
+ export function PushTagsTool(ctx: ToolContext) {
869
+ const pushPermission = ctx.payload.push;
870
+
871
+ return tool({
872
+ name: "push_tags",
873
+ description: "Push a tag to remote. Requires push: enabled permission.",
874
+ parameters: PushTags,
875
+ execute: execute(async (params) => {
876
+ if (pushPermission !== "enabled") {
877
+ throw new Error(
878
+ "Tag pushing requires push: enabled permission. " +
879
+ "Current mode only allows pushing branches.",
880
+ );
881
+ }
882
+
883
+ validateTagName(params.tag);
884
+ const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`];
885
+ await pushWithRetry(pushArgs, ctx.gitToken, ctx.payload.shell !== "enabled");
886
+ log.info(`» pushed tag ${params.tag}`);
887
+ return { success: true, tag: params.tag };
888
+ }),
889
+ });
890
+ }