infraweaver 0.1.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 (524) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +244 -0
  3. package/dist/agents/claude.d.ts +113 -0
  4. package/dist/agents/claudePretoolGate.d.ts +137 -0
  5. package/dist/agents/dispatchMetrics.d.ts +77 -0
  6. package/dist/agents/gateServer.d.ts +7 -0
  7. package/dist/agents/index.d.ts +6 -0
  8. package/dist/agents/nativeFsDenies.d.ts +46 -0
  9. package/dist/agents/opencode.d.ts +284 -0
  10. package/dist/agents/opencodePlugin.d.ts +96 -0
  11. package/dist/agents/opencodeShared.d.ts +40 -0
  12. package/dist/agents/postRun.d.ts +126 -0
  13. package/dist/agents/reviewer.d.ts +41 -0
  14. package/dist/agents/sessionLabeler.d.ts +97 -0
  15. package/dist/agents/shared.d.ts +246 -0
  16. package/dist/agents/subagentModels.d.ts +19 -0
  17. package/dist/agents/tokenQuota.d.ts +118 -0
  18. package/dist/agents/writeGateSource.d.ts +22 -0
  19. package/dist/agents/writePolicy.d.ts +79 -0
  20. package/dist/brand.d.ts +67 -0
  21. package/dist/cli.mjs +247846 -0
  22. package/dist/external.d.ts +227 -0
  23. package/dist/i18n/scaffolding.d.ts +59 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +247061 -0
  26. package/dist/internal/index.d.ts +18 -0
  27. package/dist/internal.js +2398 -0
  28. package/dist/lifecycle.d.ts +2 -0
  29. package/dist/main.d.ts +8 -0
  30. package/dist/mcp/arkConfig.d.ts +1 -0
  31. package/dist/mcp/assess.d.ts +157 -0
  32. package/dist/mcp/capabilityContext.d.ts +71 -0
  33. package/dist/mcp/changeSummary.d.ts +52 -0
  34. package/dist/mcp/checkSuite.d.ts +27 -0
  35. package/dist/mcp/checkout.d.ts +92 -0
  36. package/dist/mcp/comment.d.ts +127 -0
  37. package/dist/mcp/commitInfo.d.ts +11 -0
  38. package/dist/mcp/crosswalk.d.ts +178 -0
  39. package/dist/mcp/crosswalkDigest.d.ts +1 -0
  40. package/dist/mcp/cyberEssentials.d.ts +24 -0
  41. package/dist/mcp/dashboard.d.ts +125 -0
  42. package/dist/mcp/dependencies.d.ts +12 -0
  43. package/dist/mcp/frameworks.d.ts +74 -0
  44. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  45. package/dist/mcp/git.d.ts +60 -0
  46. package/dist/mcp/guardrails.d.ts +222 -0
  47. package/dist/mcp/issue.d.ts +20 -0
  48. package/dist/mcp/issueComments.d.ts +11 -0
  49. package/dist/mcp/issueEvents.d.ts +11 -0
  50. package/dist/mcp/issueInfo.d.ts +11 -0
  51. package/dist/mcp/labels.d.ts +14 -0
  52. package/dist/mcp/localContext.d.ts +26 -0
  53. package/dist/mcp/moduleExtraction.d.ts +79 -0
  54. package/dist/mcp/moduleTests.d.ts +106 -0
  55. package/dist/mcp/modules.d.ts +198 -0
  56. package/dist/mcp/output.d.ts +16 -0
  57. package/dist/mcp/pathSafety.d.ts +14 -0
  58. package/dist/mcp/policy.d.ts +50 -0
  59. package/dist/mcp/pr.d.ts +64 -0
  60. package/dist/mcp/prInfo.d.ts +11 -0
  61. package/dist/mcp/providerSchema.d.ts +52 -0
  62. package/dist/mcp/review.d.ts +212 -0
  63. package/dist/mcp/reviewComments.d.ts +245 -0
  64. package/dist/mcp/roots.d.ts +60 -0
  65. package/dist/mcp/scope.d.ts +26 -0
  66. package/dist/mcp/selectMode.d.ts +20 -0
  67. package/dist/mcp/server.d.ts +61 -0
  68. package/dist/mcp/shared.d.ts +62 -0
  69. package/dist/mcp/shell.d.ts +58 -0
  70. package/dist/mcp/staleFix.d.ts +138 -0
  71. package/dist/mcp/terraform/azurePrices.d.ts +8 -0
  72. package/dist/mcp/terraform/baseline.d.ts +57 -0
  73. package/dist/mcp/terraform/concernResult.d.ts +38 -0
  74. package/dist/mcp/terraform/cost.d.ts +55 -0
  75. package/dist/mcp/terraform/cspm.d.ts +80 -0
  76. package/dist/mcp/terraform/currency.d.ts +110 -0
  77. package/dist/mcp/terraform/decisions.d.ts +178 -0
  78. package/dist/mcp/terraform/eidas.d.ts +100 -0
  79. package/dist/mcp/terraform/evidence.d.ts +343 -0
  80. package/dist/mcp/terraform/findings.d.ts +76 -0
  81. package/dist/mcp/terraform/fixMemory.d.ts +81 -0
  82. package/dist/mcp/terraform/governance.d.ts +56 -0
  83. package/dist/mcp/terraform/hcl.d.ts +115 -0
  84. package/dist/mcp/terraform/iacLanguages.d.ts +120 -0
  85. package/dist/mcp/terraform/idleFloor.d.ts +78 -0
  86. package/dist/mcp/terraform/keyless.d.ts +68 -0
  87. package/dist/mcp/terraform/moduleDocs.d.ts +56 -0
  88. package/dist/mcp/terraform/nativeRules.d.ts +38 -0
  89. package/dist/mcp/terraform/nativeScan.d.ts +11 -0
  90. package/dist/mcp/terraform/noiseProfile.d.ts +100 -0
  91. package/dist/mcp/terraform/normalization.d.ts +52 -0
  92. package/dist/mcp/terraform/oscal.d.ts +103 -0
  93. package/dist/mcp/terraform/packs/azurerm-3-4.d.ts +2 -0
  94. package/dist/mcp/terraform/packs/cloudflare-4-5.d.ts +2 -0
  95. package/dist/mcp/terraform/paths.d.ts +28 -0
  96. package/dist/mcp/terraform/plan.d.ts +157 -0
  97. package/dist/mcp/terraform/planDrift.d.ts +199 -0
  98. package/dist/mcp/terraform/policyAuthor.d.ts +191 -0
  99. package/dist/mcp/terraform/prowlerOcsf.d.ts +136 -0
  100. package/dist/mcp/terraform/refactor.d.ts +183 -0
  101. package/dist/mcp/terraform/registry.d.ts +108 -0
  102. package/dist/mcp/terraform/risk.d.ts +41 -0
  103. package/dist/mcp/terraform/scanSession.d.ts +116 -0
  104. package/dist/mcp/terraform/scannerCache.d.ts +15 -0
  105. package/dist/mcp/terraform/scannerEgress.d.ts +72 -0
  106. package/dist/mcp/terraform/scannerJson.d.ts +13 -0
  107. package/dist/mcp/terraform/scanners.d.ts +205 -0
  108. package/dist/mcp/terraform/signing.d.ts +61 -0
  109. package/dist/mcp/terraform/stateLocking.d.ts +18 -0
  110. package/dist/mcp/terraform/subprocess.d.ts +43 -0
  111. package/dist/mcp/terraform/suppressions.d.ts +90 -0
  112. package/dist/mcp/terraform/taxonomy.d.ts +39 -0
  113. package/dist/mcp/terraform/tfquery.d.ts +47 -0
  114. package/dist/mcp/terraform/toolchainIntegrity.d.ts +82 -0
  115. package/dist/mcp/terraform/tools/authorPolicy.d.ts +59 -0
  116. package/dist/mcp/terraform/tools/detectPlanDrift.d.ts +20 -0
  117. package/dist/mcp/terraform/tools/emitCost.d.ts +11 -0
  118. package/dist/mcp/terraform/tools/emitSarif.d.ts +14 -0
  119. package/dist/mcp/terraform/tools/fixMemory.d.ts +19 -0
  120. package/dist/mcp/terraform/tools/infracostDiff.d.ts +22 -0
  121. package/dist/mcp/terraform/tools/ingestExternalFindings.d.ts +21 -0
  122. package/dist/mcp/terraform/tools/moduleLookup.d.ts +14 -0
  123. package/dist/mcp/terraform/tools/moduleSearch.d.ts +23 -0
  124. package/dist/mcp/terraform/tools/plan.d.ts +59 -0
  125. package/dist/mcp/terraform/tools/providerUpgrade.d.ts +55 -0
  126. package/dist/mcp/terraform/tools/readFindings.d.ts +17 -0
  127. package/dist/mcp/terraform/tools/scan.d.ts +114 -0
  128. package/dist/mcp/terraform/tools/validate.d.ts +31 -0
  129. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +31 -0
  130. package/dist/mcp/terraform/tools/versionCurrency.d.ts +5 -0
  131. package/dist/mcp/terraform/tools.d.ts +16 -0
  132. package/dist/mcp/terraform/transformPacks.d.ts +267 -0
  133. package/dist/mcp/terraform/types.d.ts +226 -0
  134. package/dist/mcp/terraform/verification.d.ts +81 -0
  135. package/dist/mcp/terraform/vex.d.ts +115 -0
  136. package/dist/mcp/terraform/writeOnly.d.ts +67 -0
  137. package/dist/mcp/terraform.d.ts +51 -0
  138. package/dist/mcp/terratest.d.ts +85 -0
  139. package/dist/mcp/untrustedContent.d.ts +76 -0
  140. package/dist/mcp/upload.d.ts +8 -0
  141. package/dist/models.d.ts +181 -0
  142. package/dist/modes/address-reviews.d.ts +2 -0
  143. package/dist/modes/assess.d.ts +2 -0
  144. package/dist/modes/build.d.ts +2 -0
  145. package/dist/modes/compliance-audit.d.ts +2 -0
  146. package/dist/modes/cost-optimization.d.ts +2 -0
  147. package/dist/modes/drift-detect.d.ts +2 -0
  148. package/dist/modes/fix.d.ts +2 -0
  149. package/dist/modes/incremental-review.d.ts +2 -0
  150. package/dist/modes/index.d.ts +24 -0
  151. package/dist/modes/modernize-deprecated.d.ts +2 -0
  152. package/dist/modes/plan.d.ts +2 -0
  153. package/dist/modes/policy-gate.d.ts +2 -0
  154. package/dist/modes/prFormats.d.ts +3 -0
  155. package/dist/modes/refactor.d.ts +2 -0
  156. package/dist/modes/refresh-remediation.d.ts +2 -0
  157. package/dist/modes/remediate-and-refactor.d.ts +2 -0
  158. package/dist/modes/remediate.d.ts +2 -0
  159. package/dist/modes/resolve-conflicts.d.ts +2 -0
  160. package/dist/modes/review.d.ts +2 -0
  161. package/dist/modes/summarize-pr.d.ts +2 -0
  162. package/dist/modes/task.d.ts +2 -0
  163. package/dist/modes/terraform-code-review.d.ts +2 -0
  164. package/dist/modes/types.d.ts +9 -0
  165. package/dist/modes/update-dependencies.d.ts +2 -0
  166. package/dist/prep/index.d.ts +7 -0
  167. package/dist/prep/installNodeDependencies.d.ts +2 -0
  168. package/dist/prep/installPythonDependencies.d.ts +2 -0
  169. package/dist/prep/types.d.ts +31 -0
  170. package/dist/reviewQuality.d.ts +107 -0
  171. package/dist/skills/terraform-best-practices/SKILL.md +379 -0
  172. package/dist/toolState.d.ts +123 -0
  173. package/dist/utils/activity.d.ts +40 -0
  174. package/dist/utils/agent.d.ts +47 -0
  175. package/dist/utils/agentHangReport.d.ts +38 -0
  176. package/dist/utils/annotations.d.ts +17 -0
  177. package/dist/utils/apiFetch.d.ts +11 -0
  178. package/dist/utils/apiKeys.d.ts +48 -0
  179. package/dist/utils/apiUrl.d.ts +28 -0
  180. package/dist/utils/assets.d.ts +8 -0
  181. package/dist/utils/baseRefConfig.d.ts +121 -0
  182. package/dist/utils/billingErrors.d.ts +85 -0
  183. package/dist/utils/body.d.ts +34 -0
  184. package/dist/utils/buildInfraweaverFooter.d.ts +29 -0
  185. package/dist/utils/byokFallback.d.ts +85 -0
  186. package/dist/utils/changeImpact.d.ts +101 -0
  187. package/dist/utils/claudeSubscription.d.ts +30 -0
  188. package/dist/utils/cli.d.ts +10 -0
  189. package/dist/utils/cloudClient.d.ts +33 -0
  190. package/dist/utils/cloudFetchCore.d.ts +42 -0
  191. package/dist/utils/cloudReport.d.ts +114 -0
  192. package/dist/utils/codexHome.d.ts +29 -0
  193. package/dist/utils/codexOAuth.d.ts +60 -0
  194. package/dist/utils/diffCoverage.d.ts +63 -0
  195. package/dist/utils/env.d.ts +46 -0
  196. package/dist/utils/errorReport.d.ts +17 -0
  197. package/dist/utils/exitHandler.d.ts +8 -0
  198. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  199. package/dist/utils/gitAuth.d.ts +84 -0
  200. package/dist/utils/gitAuthServer.d.ts +24 -0
  201. package/dist/utils/github.d.ts +104 -0
  202. package/dist/utils/globals.d.ts +3 -0
  203. package/dist/utils/infraweaverConfig.d.ts +56 -0
  204. package/dist/utils/install.d.ts +37 -0
  205. package/dist/utils/instructions.d.ts +48 -0
  206. package/dist/utils/keylessOidc.d.ts +28 -0
  207. package/dist/utils/leapingComment.d.ts +11 -0
  208. package/dist/utils/learnings.d.ts +62 -0
  209. package/dist/utils/learningsTruncate.d.ts +25 -0
  210. package/dist/utils/lifecycle.d.ts +57 -0
  211. package/dist/utils/log.d.ts +111 -0
  212. package/dist/utils/modelResolver.d.ts +63 -0
  213. package/dist/utils/moduleFetch.d.ts +39 -0
  214. package/dist/utils/normalizeEnv.d.ts +30 -0
  215. package/dist/utils/openCodeModels.d.ts +11 -0
  216. package/dist/utils/openaiCompatible.d.ts +26 -0
  217. package/dist/utils/opentofuMcp.d.ts +50 -0
  218. package/dist/utils/overrides.d.ts +40 -0
  219. package/dist/utils/packageManager.d.ts +49 -0
  220. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  221. package/dist/utils/payload.d.ts +249 -0
  222. package/dist/utils/prSummary.d.ts +61 -0
  223. package/dist/utils/presets.d.ts +36 -0
  224. package/dist/utils/progressComment.d.ts +146 -0
  225. package/dist/utils/providerErrors.d.ts +31 -0
  226. package/dist/utils/proxyModel.d.ts +76 -0
  227. package/dist/utils/rangeDiff.d.ts +51 -0
  228. package/dist/utils/redact.d.ts +33 -0
  229. package/dist/utils/registryAuth.d.ts +52 -0
  230. package/dist/utils/remediationCommand.d.ts +58 -0
  231. package/dist/utils/retry.d.ts +38 -0
  232. package/dist/utils/reviewCleanup.d.ts +14 -0
  233. package/dist/utils/run.d.ts +9 -0
  234. package/dist/utils/runContext.d.ts +112 -0
  235. package/dist/utils/runContextData.d.ts +42 -0
  236. package/dist/utils/runErrorRenderer.d.ts +64 -0
  237. package/dist/utils/runLifecycle.d.ts +89 -0
  238. package/dist/utils/runStartupLog.d.ts +15 -0
  239. package/dist/utils/secrets.d.ts +34 -0
  240. package/dist/utils/setup.d.ts +90 -0
  241. package/dist/utils/shell.d.ts +44 -0
  242. package/dist/utils/skills.d.ts +10 -0
  243. package/dist/utils/subprocess.d.ts +81 -0
  244. package/dist/utils/terraformMcp.d.ts +46 -0
  245. package/dist/utils/time.d.ts +15 -0
  246. package/dist/utils/timer.d.ts +23 -0
  247. package/dist/utils/todoTracking.d.ts +16 -0
  248. package/dist/utils/token.d.ts +52 -0
  249. package/dist/utils/toolLicensing.d.ts +56 -0
  250. package/dist/utils/toolSelection.d.ts +73 -0
  251. package/dist/utils/toon.d.ts +16 -0
  252. package/dist/utils/version.d.ts +2 -0
  253. package/dist/utils/versioning.d.ts +7 -0
  254. package/dist/utils/vertex.d.ts +16 -0
  255. package/dist/utils/workflow.d.ts +13 -0
  256. package/package.json +132 -0
  257. package/src/agents/claude.ts +1588 -0
  258. package/src/agents/claudePretoolGate.ts +286 -0
  259. package/src/agents/dispatchMetrics.ts +162 -0
  260. package/src/agents/gateServer.ts +178 -0
  261. package/src/agents/index.ts +10 -0
  262. package/src/agents/nativeFsDenies.ts +164 -0
  263. package/src/agents/opencode.ts +1600 -0
  264. package/src/agents/opencodePlugin.ts +304 -0
  265. package/src/agents/opencodeShared.ts +134 -0
  266. package/src/agents/postRun.ts +615 -0
  267. package/src/agents/reviewer.ts +134 -0
  268. package/src/agents/sessionLabeler.ts +185 -0
  269. package/src/agents/shared.ts +377 -0
  270. package/src/agents/subagentModels.ts +40 -0
  271. package/src/agents/tokenQuota.ts +203 -0
  272. package/src/agents/writeGateSource.ts +148 -0
  273. package/src/agents/writePolicy.ts +132 -0
  274. package/src/brand.ts +84 -0
  275. package/src/cli.ts +117 -0
  276. package/src/commands/gha.ts +188 -0
  277. package/src/commands/mcp.ts +126 -0
  278. package/src/commands/verifyEvidence.ts +100 -0
  279. package/src/entry.ts +7 -0
  280. package/src/entryPost.ts +109 -0
  281. package/src/external.ts +308 -0
  282. package/src/i18n/scaffolding.ts +191 -0
  283. package/src/index.ts +7 -0
  284. package/src/internal/index.ts +74 -0
  285. package/src/lifecycle.ts +2 -0
  286. package/src/main.ts +815 -0
  287. package/src/mcp/__fixtures__/infraweaver-scratch-pr-49-review-3485940013.json +110 -0
  288. package/src/mcp/__fixtures__/infraweaver-scratch-pr-64-review-3531000326.json +14 -0
  289. package/src/mcp/__fixtures__/infraweaver-test-repo-pr-1.diff.json +67 -0
  290. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  291. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  292. package/src/mcp/arkConfig.ts +7 -0
  293. package/src/mcp/assess.ts +723 -0
  294. package/src/mcp/capabilityContext.ts +80 -0
  295. package/src/mcp/changeSummary.ts +145 -0
  296. package/src/mcp/checkSuite.ts +261 -0
  297. package/src/mcp/checkout.ts +1015 -0
  298. package/src/mcp/comment.ts +683 -0
  299. package/src/mcp/commitInfo.ts +57 -0
  300. package/src/mcp/crosswalk.ts +1253 -0
  301. package/src/mcp/crosswalkDigest.ts +5 -0
  302. package/src/mcp/cyberEssentials.ts +60 -0
  303. package/src/mcp/dashboard.ts +232 -0
  304. package/src/mcp/dependencies.ts +190 -0
  305. package/src/mcp/frameworks.ts +236 -0
  306. package/src/mcp/geminiSanitizer.ts +212 -0
  307. package/src/mcp/git.ts +1116 -0
  308. package/src/mcp/guardrails.ts +771 -0
  309. package/src/mcp/issue.ts +74 -0
  310. package/src/mcp/issueComments.ts +48 -0
  311. package/src/mcp/issueEvents.ts +100 -0
  312. package/src/mcp/issueInfo.ts +72 -0
  313. package/src/mcp/labels.ts +35 -0
  314. package/src/mcp/localContext.ts +65 -0
  315. package/src/mcp/localServer.ts +217 -0
  316. package/src/mcp/moduleExtraction.ts +368 -0
  317. package/src/mcp/moduleTests.ts +421 -0
  318. package/src/mcp/modules.ts +752 -0
  319. package/src/mcp/output.ts +71 -0
  320. package/src/mcp/pathSafety.ts +28 -0
  321. package/src/mcp/policy.ts +226 -0
  322. package/src/mcp/pr.ts +238 -0
  323. package/src/mcp/prInfo.ts +91 -0
  324. package/src/mcp/providerSchema.ts +175 -0
  325. package/src/mcp/review.ts +1081 -0
  326. package/src/mcp/reviewComments.ts +1137 -0
  327. package/src/mcp/roots.ts +217 -0
  328. package/src/mcp/scope.ts +82 -0
  329. package/src/mcp/selectMode.ts +207 -0
  330. package/src/mcp/server.ts +498 -0
  331. package/src/mcp/shared.ts +120 -0
  332. package/src/mcp/shell.ts +631 -0
  333. package/src/mcp/staleFix.ts +557 -0
  334. package/src/mcp/terraform/__snapshots__/moduleDocs.test.ts.snap +40 -0
  335. package/src/mcp/terraform/azurePrices.ts +38 -0
  336. package/src/mcp/terraform/baseline.ts +172 -0
  337. package/src/mcp/terraform/concernResult.ts +111 -0
  338. package/src/mcp/terraform/cost.ts +175 -0
  339. package/src/mcp/terraform/cspm.ts +326 -0
  340. package/src/mcp/terraform/currency.ts +354 -0
  341. package/src/mcp/terraform/decisions.ts +590 -0
  342. package/src/mcp/terraform/eidas.ts +134 -0
  343. package/src/mcp/terraform/evidence.ts +771 -0
  344. package/src/mcp/terraform/findings.ts +383 -0
  345. package/src/mcp/terraform/fixMemory.ts +253 -0
  346. package/src/mcp/terraform/governance.ts +231 -0
  347. package/src/mcp/terraform/hcl.ts +273 -0
  348. package/src/mcp/terraform/iacLanguages.ts +431 -0
  349. package/src/mcp/terraform/idleFloor.ts +304 -0
  350. package/src/mcp/terraform/keyless.ts +123 -0
  351. package/src/mcp/terraform/moduleDocs.ts +303 -0
  352. package/src/mcp/terraform/nativeRules.ts +152 -0
  353. package/src/mcp/terraform/nativeScan.ts +67 -0
  354. package/src/mcp/terraform/noiseProfile.ts +183 -0
  355. package/src/mcp/terraform/normalization.ts +265 -0
  356. package/src/mcp/terraform/oscal.ts +283 -0
  357. package/src/mcp/terraform/packs/azurerm-3-4.ts +382 -0
  358. package/src/mcp/terraform/packs/cloudflare-4-5.ts +114 -0
  359. package/src/mcp/terraform/paths.ts +59 -0
  360. package/src/mcp/terraform/plan.ts +377 -0
  361. package/src/mcp/terraform/planDrift.ts +520 -0
  362. package/src/mcp/terraform/policyAuthor.ts +886 -0
  363. package/src/mcp/terraform/prowlerOcsf.ts +380 -0
  364. package/src/mcp/terraform/refactor.ts +879 -0
  365. package/src/mcp/terraform/registry.ts +317 -0
  366. package/src/mcp/terraform/risk.ts +99 -0
  367. package/src/mcp/terraform/scanSession.ts +242 -0
  368. package/src/mcp/terraform/scannerCache.ts +86 -0
  369. package/src/mcp/terraform/scannerEgress.ts +141 -0
  370. package/src/mcp/terraform/scannerJson.ts +22 -0
  371. package/src/mcp/terraform/scanners.ts +1134 -0
  372. package/src/mcp/terraform/signing.ts +151 -0
  373. package/src/mcp/terraform/stateLocking.ts +106 -0
  374. package/src/mcp/terraform/subprocess.ts +102 -0
  375. package/src/mcp/terraform/suppressions.ts +551 -0
  376. package/src/mcp/terraform/taxonomy.ts +0 -0
  377. package/src/mcp/terraform/tfquery.ts +159 -0
  378. package/src/mcp/terraform/toolchainIntegrity.ts +151 -0
  379. package/src/mcp/terraform/tools/authorPolicy.ts +185 -0
  380. package/src/mcp/terraform/tools/detectPlanDrift.ts +279 -0
  381. package/src/mcp/terraform/tools/emitCost.ts +130 -0
  382. package/src/mcp/terraform/tools/emitSarif.ts +108 -0
  383. package/src/mcp/terraform/tools/fixMemory.ts +82 -0
  384. package/src/mcp/terraform/tools/infracostDiff.ts +110 -0
  385. package/src/mcp/terraform/tools/ingestExternalFindings.ts +259 -0
  386. package/src/mcp/terraform/tools/moduleLookup.ts +86 -0
  387. package/src/mcp/terraform/tools/moduleSearch.ts +46 -0
  388. package/src/mcp/terraform/tools/plan.ts +384 -0
  389. package/src/mcp/terraform/tools/providerUpgrade.ts +217 -0
  390. package/src/mcp/terraform/tools/readFindings.ts +138 -0
  391. package/src/mcp/terraform/tools/scan.ts +303 -0
  392. package/src/mcp/terraform/tools/validate.ts +160 -0
  393. package/src/mcp/terraform/tools/verifyRemediation.ts +190 -0
  394. package/src/mcp/terraform/tools/versionCurrency.ts +64 -0
  395. package/src/mcp/terraform/tools.ts +62 -0
  396. package/src/mcp/terraform/transformPacks.ts +594 -0
  397. package/src/mcp/terraform/types.ts +382 -0
  398. package/src/mcp/terraform/verification.ts +150 -0
  399. package/src/mcp/terraform/vex.ts +367 -0
  400. package/src/mcp/terraform/writeOnly.ts +252 -0
  401. package/src/mcp/terraform.ts +52 -0
  402. package/src/mcp/terratest.ts +215 -0
  403. package/src/mcp/untrustedContent.ts +126 -0
  404. package/src/mcp/upload.ts +123 -0
  405. package/src/models.ts +753 -0
  406. package/src/modes/address-reviews.ts +35 -0
  407. package/src/modes/assess.ts +27 -0
  408. package/src/modes/build.ts +86 -0
  409. package/src/modes/compliance-audit.ts +25 -0
  410. package/src/modes/cost-optimization.ts +36 -0
  411. package/src/modes/drift-detect.ts +31 -0
  412. package/src/modes/fix.ts +29 -0
  413. package/src/modes/incremental-review.ts +103 -0
  414. package/src/modes/index.ts +94 -0
  415. package/src/modes/modernize-deprecated.ts +36 -0
  416. package/src/modes/plan.ts +21 -0
  417. package/src/modes/policy-gate.ts +27 -0
  418. package/src/modes/prFormats.ts +325 -0
  419. package/src/modes/refactor.ts +74 -0
  420. package/src/modes/refresh-remediation.ts +40 -0
  421. package/src/modes/remediate-and-refactor.ts +42 -0
  422. package/src/modes/remediate.ts +78 -0
  423. package/src/modes/resolve-conflicts.ts +32 -0
  424. package/src/modes/review.ts +135 -0
  425. package/src/modes/summarize-pr.ts +42 -0
  426. package/src/modes/task.ts +26 -0
  427. package/src/modes/terraform-code-review.ts +68 -0
  428. package/src/modes/types.ts +17 -0
  429. package/src/modes/update-dependencies.ts +44 -0
  430. package/src/prep/index.ts +96 -0
  431. package/src/prep/installNodeDependencies.ts +251 -0
  432. package/src/prep/installPythonDependencies.ts +235 -0
  433. package/src/prep/types.ts +38 -0
  434. package/src/reviewQuality.ts +237 -0
  435. package/src/runCli.ts +335 -0
  436. package/src/skills/terraform-best-practices/SKILL.md +379 -0
  437. package/src/toolState.ts +241 -0
  438. package/src/utils/activity.ts +210 -0
  439. package/src/utils/agent.ts +245 -0
  440. package/src/utils/agentHangReport.ts +180 -0
  441. package/src/utils/annotations.ts +56 -0
  442. package/src/utils/apiFetch.ts +19 -0
  443. package/src/utils/apiKeys.ts +305 -0
  444. package/src/utils/apiUrl.ts +42 -0
  445. package/src/utils/assets.ts +123 -0
  446. package/src/utils/baseRefConfig.ts +254 -0
  447. package/src/utils/billingErrors.ts +210 -0
  448. package/src/utils/body.ts +168 -0
  449. package/src/utils/buildInfraweaverFooter.ts +104 -0
  450. package/src/utils/byokFallback.ts +137 -0
  451. package/src/utils/changeImpact.ts +330 -0
  452. package/src/utils/claudeSubscription.ts +93 -0
  453. package/src/utils/cli.ts +36 -0
  454. package/src/utils/cloudClient.ts +57 -0
  455. package/src/utils/cloudFetchCore.ts +139 -0
  456. package/src/utils/cloudReport.ts +316 -0
  457. package/src/utils/codexHome.ts +204 -0
  458. package/src/utils/codexOAuth.ts +154 -0
  459. package/src/utils/codexRefreshDetect.ts +36 -0
  460. package/src/utils/diffCoverage.ts +404 -0
  461. package/src/utils/env.ts +84 -0
  462. package/src/utils/errorReport.ts +94 -0
  463. package/src/utils/exitHandler.ts +35 -0
  464. package/src/utils/fixDoubleEscapedString.ts +9 -0
  465. package/src/utils/ghaCore.ts +13 -0
  466. package/src/utils/gitAuth.ts +268 -0
  467. package/src/utils/gitAuthServer.ts +186 -0
  468. package/src/utils/github.ts +676 -0
  469. package/src/utils/globals.ts +9 -0
  470. package/src/utils/humanEditCapture.ts +198 -0
  471. package/src/utils/infraweaverConfig.ts +202 -0
  472. package/src/utils/install.ts +279 -0
  473. package/src/utils/instructions.ts +640 -0
  474. package/src/utils/keylessOidc.ts +47 -0
  475. package/src/utils/leapingComment.ts +20 -0
  476. package/src/utils/learnings.ts +145 -0
  477. package/src/utils/learningsTruncate.ts +42 -0
  478. package/src/utils/lifecycle.ts +198 -0
  479. package/src/utils/log.ts +452 -0
  480. package/src/utils/modelResolver.ts +105 -0
  481. package/src/utils/moduleFetch.ts +138 -0
  482. package/src/utils/normalizeEnv.ts +106 -0
  483. package/src/utils/openCodeModels.ts +86 -0
  484. package/src/utils/openaiCompatible.ts +52 -0
  485. package/src/utils/opentofuMcp.ts +61 -0
  486. package/src/utils/overrides.ts +100 -0
  487. package/src/utils/packageManager.ts +257 -0
  488. package/src/utils/patchWorkflowRunFields.ts +173 -0
  489. package/src/utils/payload.ts +1206 -0
  490. package/src/utils/prSummary.ts +148 -0
  491. package/src/utils/presets.ts +105 -0
  492. package/src/utils/progressComment.ts +266 -0
  493. package/src/utils/providerErrors.ts +189 -0
  494. package/src/utils/proxyModel.ts +239 -0
  495. package/src/utils/rangeDiff.ts +182 -0
  496. package/src/utils/redact.ts +86 -0
  497. package/src/utils/registryAuth.ts +115 -0
  498. package/src/utils/remediationCommand.ts +144 -0
  499. package/src/utils/retry.ts +180 -0
  500. package/src/utils/reviewCleanup.ts +116 -0
  501. package/src/utils/run.ts +100 -0
  502. package/src/utils/runContext.ts +276 -0
  503. package/src/utils/runContextData.ts +121 -0
  504. package/src/utils/runErrorRenderer.ts +282 -0
  505. package/src/utils/runFixture.ts +91 -0
  506. package/src/utils/runLifecycle.ts +360 -0
  507. package/src/utils/runStartupLog.ts +67 -0
  508. package/src/utils/secrets.ts +208 -0
  509. package/src/utils/setup.ts +368 -0
  510. package/src/utils/shell.ts +132 -0
  511. package/src/utils/skills.ts +67 -0
  512. package/src/utils/subprocess.ts +474 -0
  513. package/src/utils/terraformMcp.ts +99 -0
  514. package/src/utils/time.ts +59 -0
  515. package/src/utils/timer.ts +72 -0
  516. package/src/utils/todoTracking.ts +168 -0
  517. package/src/utils/token.ts +294 -0
  518. package/src/utils/toolLicensing.ts +152 -0
  519. package/src/utils/toolSelection.ts +239 -0
  520. package/src/utils/toon.ts +76 -0
  521. package/src/utils/version.ts +10 -0
  522. package/src/utils/versioning.ts +44 -0
  523. package/src/utils/vertex.ts +94 -0
  524. package/src/utils/workflow.ts +25 -0
package/src/mcp/git.ts ADDED
@@ -0,0 +1,1116 @@
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 type { GitToolContext } from "#app/mcp/capabilityContext";
7
+ import {
8
+ assertNoBlockedDestroy,
9
+ assertNoSecretsInDiff,
10
+ assertNoVendoredModuleEdits,
11
+ assertRefactorEquivalence,
12
+ enforceProtectedPaths,
13
+ enforceRemediationPaths,
14
+ } from "#app/mcp/guardrails";
15
+ import { execute, tool } from "#app/mcp/shared";
16
+ import type { StoredPushDest } from "#app/toolState";
17
+ import { log } from "#app/utils/cli";
18
+ import { getTempDir } from "#app/utils/env";
19
+ import { $git, $gitFetchWithDeepen } from "#app/utils/gitAuth";
20
+ import {
21
+ executeLifecycleHook,
22
+ type LifecycleHookFailure,
23
+ } from "#app/utils/lifecycle";
24
+ import { $ } from "#app/utils/shell";
25
+
26
+ type PushDestination = {
27
+ remoteName: string;
28
+ remoteBranch: string;
29
+ url: string;
30
+ };
31
+
32
+ /**
33
+ * get where git would actually push this branch.
34
+ * prefers the stored destination from toolState (set by checkout_pr) when it
35
+ * matches the current branch, because git config reads can silently fail in
36
+ * certain environments causing pushes to the wrong remote branch.
37
+ *
38
+ * falls back to reading branch.X.pushRemote and branch.X.merge from git config,
39
+ * and finally to origin/<branch> for branches created without checkout_pr.
40
+ */
41
+ function getPushDestination(
42
+ branch: string,
43
+ storedDest: StoredPushDest | undefined,
44
+ ): PushDestination {
45
+ // prefer stored destination from checkout_pr when it matches the current branch
46
+ if (storedDest && storedDest.localBranch === branch) {
47
+ log.debug(
48
+ `using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`,
49
+ );
50
+ const url = $(
51
+ "git",
52
+ ["remote", "get-url", "--push", storedDest.remoteName],
53
+ {
54
+ log: false,
55
+ },
56
+ ).trim();
57
+ return {
58
+ remoteName: storedDest.remoteName,
59
+ remoteBranch: storedDest.remoteBranch,
60
+ url,
61
+ };
62
+ }
63
+
64
+ // fall back to git config (for branches not created by checkout_pr)
65
+ try {
66
+ const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], {
67
+ log: false,
68
+ }).trim();
69
+ const merge = $("git", ["config", `branch.${branch}.merge`], {
70
+ log: false,
71
+ }).trim();
72
+ const remoteBranch = merge.replace(/^refs\/heads\//, "");
73
+ const url = $("git", ["remote", "get-url", "--push", pushRemote], {
74
+ log: false,
75
+ }).trim();
76
+ return { remoteName: pushRemote, remoteBranch, url };
77
+ } catch {
78
+ // no push config - branch was created locally without checkout_pr
79
+ log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
80
+ const url = $("git", ["remote", "get-url", "--push", "origin"], {
81
+ log: false,
82
+ }).trim();
83
+ return { remoteName: "origin", remoteBranch: branch, url };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * normalize URL for comparison (handle .git suffix, case)
89
+ */
90
+ function normalizeUrl(url: string): string {
91
+ return url.replace(/\.git$/, "").toLowerCase();
92
+ }
93
+
94
+ // SECURITY: reject refs/branch names that begin with "-". git's parseopt
95
+ // accepts options intermixed with positional args, so a ref like
96
+ // "--upload-pack=evil" could be interpreted as a flag rather than a refspec.
97
+ export function rejectIfLeadingDash(value: string, kind: string): void {
98
+ if (value.startsWith("-")) {
99
+ throw new Error(
100
+ `Blocked: ${kind} '${value}' starts with '-' — git could parse it as a flag.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ // SECURITY: branch inputs to push/delete must be bare branch names. a branch
106
+ // name like "refs/heads/main" bypasses the restricted-mode default-branch
107
+ // check below (which does exact-string compare against "main"), and symbolic
108
+ // refs (HEAD / FETCH_HEAD / ORIG_HEAD / MERGE_HEAD) would resolve to
109
+ // whatever commit those refs point at — both routes let an agent push to
110
+ // protected branches even under push: restricted. checkout_pr only ever
111
+ // stores bare names like "pr-123", so nothing legitimate relies on the
112
+ // refs/... form here.
113
+ const SYMBOLIC_REFS = new Set([
114
+ "HEAD",
115
+ "FETCH_HEAD",
116
+ "ORIG_HEAD",
117
+ "MERGE_HEAD",
118
+ ]);
119
+ export function rejectSpecialRef(value: string, kind: string): void {
120
+ rejectIfLeadingDash(value, kind);
121
+ if (value.startsWith("refs/")) {
122
+ throw new Error(
123
+ `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.`,
124
+ );
125
+ }
126
+ if (SYMBOLIC_REFS.has(value)) {
127
+ throw new Error(
128
+ `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.`,
129
+ );
130
+ }
131
+ // SECURITY: git interprets ':' and leading '+' as refspec syntax, not as
132
+ // part of a branch name. without this check, an agent under push:restricted
133
+ // can smuggle a full refspec through branchName:
134
+ // - "evil:refs/heads/main" → pushes local 'evil' to remote main
135
+ // - ":refs/heads/main" → deletes remote main
136
+ // - ":other" → deletes remote 'other' under push:restricted
137
+ // - "+main" → force-push refspec
138
+ // the default-branch guard downstream is an exact-string compare, so any
139
+ // character that lets git parse the value as <src>:<dst> (or as a force
140
+ // prefix) bypasses it. git's own check-ref-format forbids ':', '+', '^',
141
+ // '~', '?', '*', '[', '\\', and whitespace in branch names, so rejecting
142
+ // them here cannot false-positive against a legitimate branch name.
143
+ const BAD = /[:+^~?*[\\\s]/;
144
+ const badMatch = value.match(BAD);
145
+ if (badMatch) {
146
+ throw new Error(
147
+ `Blocked: ${kind} '${value}' contains '${badMatch[0]}', which git interprets as refspec/revision syntax, not as part of a branch name.`,
148
+ );
149
+ }
150
+ }
151
+
152
+ // SECURITY: validate tag names so the push_tags refspec can't be split into
153
+ // a <src>:<dst> refspec that targets a non-tag ref. without this, a tag like
154
+ // "foo:refs/heads/main" becomes "refs/tags/foo:refs/heads/main" and git
155
+ // pushes the local tag's commit to remote main — a back door around the
156
+ // branch-push rules in push_branch. keep the allow-list conservative (git's
157
+ // own check-ref-format forbids far more, but we only need enough to block
158
+ // refspec injection).
159
+ export function validateTagName(tag: string): void {
160
+ rejectIfLeadingDash(tag, "tag");
161
+ if (!/^[A-Za-z0-9._/-]+$/.test(tag)) {
162
+ throw new Error(
163
+ `Blocked: tag '${tag}' contains characters that could be parsed as a refspec or flag. Tags must match [A-Za-z0-9._/-]+.`,
164
+ );
165
+ }
166
+ }
167
+
168
+ /**
169
+ * validate that the push destination matches expected URL.
170
+ * pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
171
+ */
172
+ function validatePushDestination(
173
+ ctx: GitToolContext,
174
+ branch: string,
175
+ ): PushDestination {
176
+ const pushUrl = ctx.toolState.pushUrl;
177
+ if (!pushUrl)
178
+ throw new Error("pushUrl not set - setupGit must run before push_branch");
179
+
180
+ const dest = getPushDestination(branch, ctx.toolState.pushDest);
181
+
182
+ if (normalizeUrl(dest.url) !== normalizeUrl(pushUrl)) {
183
+ throw new Error(
184
+ `Push blocked: destination does not match expected repository.\n` +
185
+ `Expected: ${pushUrl}\n` +
186
+ `Actual: ${dest.url}\n` +
187
+ `Git configuration may have been tampered with.`,
188
+ );
189
+ }
190
+
191
+ return dest;
192
+ }
193
+
194
+ export const PushBranch = type({
195
+ branchName: type.string
196
+ .describe("The branch name to push (defaults to current branch)")
197
+ .optional(),
198
+ force: type.boolean.describe("Force push (use with caution)").default(false),
199
+ });
200
+
201
+ // classify an error from `$git("push", ...)` to decide retry vs. recovery
202
+ // vs. rethrow. exported for tests.
203
+ //
204
+ // - `concurrent-push`: server-side compare-and-swap failed because the ref
205
+ // advanced between fetch and push. recovery is fetch + integrate + retry.
206
+ // matches both the client-side detection (`fetch first` /
207
+ // `non-fast-forward`) and the server-side detection (`cannot lock ref`
208
+ // with `is at <SHA1> but expected <SHA2>`).
209
+ // - `transient`: network or upstream server hiccup (RPC failed mid-stream,
210
+ // HTTP 5xx, early EOF, reset, timeout, dns flake). push is idempotent so
211
+ // verbatim retry with backoff is safe.
212
+ // - `unknown`: anything else (including auth/permission/protected-branch
213
+ // rejections). retrying these wastes time; surface to the caller.
214
+ //
215
+ // kept conservative: a misclassification of `unknown` -> `transient` would
216
+ // cause two extra round-trips on a permanently-failing push, while the
217
+ // reverse (true transient labeled `unknown`) just falls back to current
218
+ // behavior. so we only mark as transient when the error string is
219
+ // unambiguously a network/server-side fault, not a refusal.
220
+ export type PushErrorKind =
221
+ | "concurrent-push"
222
+ | "transient"
223
+ | "transient-auth"
224
+ | "unknown";
225
+
226
+ const CONCURRENT_PUSH_PATTERNS = [
227
+ "fetch first",
228
+ "non-fast-forward",
229
+ "cannot lock ref",
230
+ ] as const;
231
+
232
+ // network/server-side faults — the token is fine, the wire blipped. backoff-retry
233
+ // the SAME token.
234
+ const TRANSIENT_PATTERNS: RegExp[] = [
235
+ /RPC failed/i,
236
+ /early EOF/,
237
+ /the remote end hung up unexpectedly/,
238
+ /Connection reset/i,
239
+ /Could not resolve host/i,
240
+ /Operation timed out/i,
241
+ /HTTP\/2 stream \d+ was not closed cleanly/i,
242
+ /unexpected disconnect while reading sideband packet/i,
243
+ // libcurl HTTP 5xx surfaced by git over https. matches both the
244
+ // libcurl-style "The requested URL returned error: 502" and the more
245
+ // recent "HTTP 502" wording. most 4xx is intentionally excluded —
246
+ // 401/403/404 indicate auth/permission problems that are not
247
+ // retry-safe — but 429 (rate-limited / abuse detection) IS retry-safe
248
+ // and GitHub occasionally surfaces it on git push, so it's included
249
+ // explicitly below.
250
+ /HTTP 5\d\d/,
251
+ /returned error: 5\d\d/i,
252
+ /HTTP 429/,
253
+ /returned error: 429/i,
254
+ ];
255
+
256
+ // installation-token 401s on git push. GitHub installation tokens can 401 for
257
+ // seconds after minting while replicating; git push surfaces it as "Invalid
258
+ // username or token" (distinct from 403 permission denied). Kept as its own kind
259
+ // (separate from the network transients) so pushWithRetry can apply the re-mint
260
+ // cure (pullfrog #972) when a re-minter is available: GitHub can hand out a token
261
+ // its git edge never accepts, so the same token 401s for its whole life and
262
+ // same-token backoff can't recover — re-minting gets a fresh one. When no
263
+ // re-minter is available (off-CI / external GH_TOKEN) this still falls back to the
264
+ // same-token backoff (replication lag clears within the ~60s schedule).
265
+ const TRANSIENT_AUTH_PATTERNS: RegExp[] = [
266
+ /Invalid username or token/,
267
+ /Authentication failed for 'https:\/\/github\.com\//,
268
+ ];
269
+
270
+ export function classifyPushError(msg: string): PushErrorKind {
271
+ if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p)))
272
+ return "concurrent-push";
273
+ if (TRANSIENT_AUTH_PATTERNS.some((p) => p.test(msg))) return "transient-auth";
274
+ if (TRANSIENT_PATTERNS.some((p) => p.test(msg))) return "transient";
275
+ return "unknown";
276
+ }
277
+
278
+ // exponential backoff delays before retry attempts 2-6. attempt 1 is the
279
+ // original push. total worst-case added latency: ~60s. larger than it looks
280
+ // like it needs to be, on purpose: github installation-token replication lag
281
+ // can exceed 20s, and the same token surfaces as "Invalid username or token"
282
+ // until it propagates to the push edge. re-minting does not help (a fresh
283
+ // token has the same lag), so the cure is to wait out the propagation with
284
+ // the same token. a short window reddens CI (notably the push-restricted
285
+ // e2e); ~60s rides it out while still bounding a permanently-failing push.
286
+ const TRANSIENT_RETRY_DELAYS_MS = [2000, 4000, 8000, 16000, 30000];
287
+
288
+ /**
289
+ * push with backoff retry on transient failures (network 5xx, connection
290
+ * reset, and the freshly-minted-token 401 github surfaces as "Invalid
291
+ * username or token" while the installation token replicates across edges —
292
+ * see TRANSIENT_PATTERNS / TRANSIENT_AUTH_PATTERNS). concurrent-push and
293
+ * permission rejections are not retried — they need caller intervention.
294
+ *
295
+ * shared by push_branch, push_tags, and delete_branch so all three are
296
+ * equally resilient to github's post-mint replication lag. before this,
297
+ * only push_branch retried, so a tag push or branch delete that happened to
298
+ * hit an un-replicated edge failed outright even though the token was valid.
299
+ *
300
+ * `refreshGitToken` (when provided — CI only, pullfrog #972) re-mints a fresh
301
+ * installation token on an auth-class 401 and retries with it on a short delay,
302
+ * recovering from a token GitHub's git edge never accepts (which same-token
303
+ * backoff can't fix). Without it, auth-class 401s fall back to the same-token
304
+ * backoff used for network transients.
305
+ */
306
+ async function pushWithRetry(
307
+ args: string[],
308
+ token: string,
309
+ disableHooks: boolean,
310
+ refreshGitToken?: (stale: string) => Promise<string>,
311
+ ): Promise<void> {
312
+ let currentToken = token;
313
+ let lastErr: unknown;
314
+ for (
315
+ let attempt = 0;
316
+ attempt <= TRANSIENT_RETRY_DELAYS_MS.length;
317
+ attempt++
318
+ ) {
319
+ try {
320
+ await $git("push", args, { token: currentToken, disableHooks });
321
+ if (attempt > 0) log.info(`push succeeded on attempt ${attempt + 1}`);
322
+ return;
323
+ } catch (err) {
324
+ lastErr = err;
325
+ const msg = err instanceof Error ? err.message : String(err);
326
+ const kind = classifyPushError(msg);
327
+
328
+ // last attempt, or a non-retryable error (concurrent-push / unknown /
329
+ // permission): surface to the caller.
330
+ if (attempt >= TRANSIENT_RETRY_DELAYS_MS.length) throw err;
331
+
332
+ // auth-class 401 with a re-minter available: re-acquire a fresh
333
+ // installation token (the rejected one may be poisoned for its whole life,
334
+ // so waiting it out can't recover) and retry with it on a short delay.
335
+ if (kind === "transient-auth" && refreshGitToken) {
336
+ let refreshed: string;
337
+ try {
338
+ refreshed = await refreshGitToken(currentToken);
339
+ } catch (refreshErr) {
340
+ log.warning(
341
+ `push token re-mint failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`,
342
+ );
343
+ throw err;
344
+ }
345
+ currentToken = refreshed;
346
+ const delay = Math.round(1000 * (0.75 + Math.random() * 0.5));
347
+ log.info(
348
+ `push attempt ${attempt + 1} failed (auth), re-minted token, retrying in ${delay}ms: ${msg.slice(0, 300)}`,
349
+ );
350
+ await new Promise((r) => setTimeout(r, delay));
351
+ continue;
352
+ }
353
+
354
+ // network transients (and auth-class 401s with no re-minter) ride the
355
+ // exponential backoff with the same token. jitter avoids lockstep retries
356
+ // when several agents are hit by the same upstream blip simultaneously.
357
+ if (kind === "transient" || kind === "transient-auth") {
358
+ const baseDelay = TRANSIENT_RETRY_DELAYS_MS[attempt] ?? 5000;
359
+ const delay = Math.round(baseDelay * (0.75 + Math.random() * 0.5));
360
+ log.info(
361
+ `push attempt ${attempt + 1} failed (transient), retrying in ${delay}ms: ${msg.slice(0, 300)}`,
362
+ );
363
+ await new Promise((r) => setTimeout(r, delay));
364
+ continue;
365
+ }
366
+ throw err;
367
+ }
368
+ }
369
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
370
+ }
371
+
372
+ export function PushBranchTool(ctx: GitToolContext) {
373
+ const defaultBranch = ctx.repo.data.default_branch || "main";
374
+ const pushPermission = ctx.payload.push;
375
+
376
+ return tool({
377
+ mutates: true,
378
+ name: "push_branch",
379
+ description:
380
+ "Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
381
+ 'Example: `push_branch({})` to push the current branch. Example: `push_branch({ branchName: "pr-1" })` to push a specific local branch. ' +
382
+ "If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
383
+ "The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
384
+ "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. " +
385
+ "Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode. " +
386
+ "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.",
387
+ parameters: PushBranch,
388
+ execute: execute(async ({ branchName, force }) => {
389
+ // permission check
390
+ if (pushPermission === "disabled") {
391
+ throw new Error(
392
+ "Push is disabled. This repository is configured for read-only access.",
393
+ );
394
+ }
395
+
396
+ const branch =
397
+ branchName ||
398
+ $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
399
+ // check the resolved branch too — rev-parse could surface a weird current
400
+ // branch name that would otherwise bypass the user-facing check. use
401
+ // rejectSpecialRef so "refs/heads/main" and symbolic refs like HEAD
402
+ // can't slip past the default-branch guard below.
403
+ rejectSpecialRef(branch, "branch");
404
+
405
+ // reject push if working tree is dirty — forces agent to commit or discard before pushing
406
+ const status = $("git", ["status", "--porcelain"], { log: false });
407
+ if (status) {
408
+ throw new Error(
409
+ `push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.\n\n` +
410
+ `git status:\n${status}` +
411
+ (ctx.toolState.prepushFailureCount > 0
412
+ ? "\n\nnote: the prepush hook failed earlier this run — once the working tree is clean, push_branch will skip the hook."
413
+ : ""),
414
+ );
415
+ }
416
+
417
+ // Remediate-mode guardrail: refuse to push if the run touched files
418
+ // outside the Terraform allow-list. No-op in every other mode.
419
+ enforceRemediationPaths(ctx);
420
+
421
+ // Remediate-mode guardrail: refuse to push if terraform_plan showed the
422
+ // change would destroy/replace a stateful (data-bearing) resource, unless
423
+ // the operator allowed it via `allow_replace`. No-op when no plan ran.
424
+ assertNoBlockedDestroy(ctx);
425
+
426
+ // Refactor-mode guardrail: refuse to push if terraform_equivalence_check
427
+ // could not prove the refactor preserves behaviour — most critically an
428
+ // uncovered `moved {}` block (a silent destroy/recreate). No-op when no
429
+ // equivalence check ran (i.e. every non-refactor flow).
430
+ assertRefactorEquivalence(ctx);
431
+
432
+ // Refactor-mode guardrail: refuse to push if the run edited FETCHED
433
+ // (vendored) module code — a b.1 cross-repo or b.4 registry module is
434
+ // read-only; its concerns are fixed at the call site, never in the vendored
435
+ // copy. No-op when nothing under the module cache changed.
436
+ assertNoVendoredModuleEdits(ctx);
437
+
438
+ // refuse to push if the run touched a path the operator marked
439
+ // never-auto-modify via `protected_paths`. No-op when unset.
440
+ enforceProtectedPaths(ctx);
441
+
442
+ // refuse to push if the diff inlines a literal secret (a fix must
443
+ // reference a variable/secret store, never paste the value).
444
+ assertNoSecretsInDiff(ctx);
445
+
446
+ // validate push destination matches expected URL
447
+ const pushDest = validatePushDestination(ctx, branch);
448
+
449
+ // backstop against subagent-induced cross-PR clobbers: a subagent
450
+ // shares cwd + toolState with the orchestrator, so its `checkout_pr(N)`
451
+ // moves HEAD to pr-N and persists pushDest pointing at the foreign
452
+ // PR's remote branch. refuse pr-N → origin/<other> pushes unless this
453
+ // run is itself scoped to PR N (zed-industries/cloud, 2026-05-18).
454
+ const prBranchMatch = branch.match(/^pr-(\d+)$/);
455
+ if (prBranchMatch && pushDest.remoteBranch !== branch) {
456
+ const prNumber = Number(prBranchMatch[1]);
457
+ const event = ctx.payload.event;
458
+ const runScoped =
459
+ event.is_pr === true && event.issue_number === prNumber;
460
+ if (!runScoped) {
461
+ throw new Error(
462
+ `push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', ` +
463
+ `but this run is not scoped to PR #${prNumber}. ` +
464
+ `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). ` +
465
+ `you have probably landed your commit on the wrong branch. ` +
466
+ `switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. ` +
467
+ `if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`,
468
+ );
469
+ }
470
+ }
471
+
472
+ // block pushes to default branch in restricted mode
473
+ if (
474
+ pushPermission === "restricted" &&
475
+ pushDest.remoteBranch === defaultBranch
476
+ ) {
477
+ throw new Error(
478
+ `Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. ` +
479
+ `Create a feature branch and open a PR instead.`,
480
+ );
481
+ }
482
+
483
+ // use refspec when local and remote branch names differ
484
+ const refspec =
485
+ branch === pushDest.remoteBranch
486
+ ? branch
487
+ : `${branch}:${pushDest.remoteBranch}`;
488
+ const pushArgs = force
489
+ ? ["--force", "-u", pushDest.remoteName, refspec]
490
+ : ["-u", pushDest.remoteName, refspec];
491
+
492
+ const prepushSkipped = ctx.toolState.prepushFailureCount > 0;
493
+ if (prepushSkipped) {
494
+ log.info(`» skipping prepush hook (failed earlier this run)`);
495
+ } else if (ctx.prepushScript) {
496
+ const prepushHook = await executeLifecycleHook({
497
+ event: "prepush",
498
+ script: ctx.prepushScript,
499
+ });
500
+ if (prepushHook.failure) {
501
+ ctx.toolState.prepushFailureCount += 1;
502
+ throw new Error(
503
+ buildPrepushFailureMessage(prepushHook.failure, ctx.payload.shell),
504
+ );
505
+ }
506
+
507
+ // re-verify clean working tree after prepush. a hook that writes tracked
508
+ // files (formatter, type generator, build artifacts) would leave those
509
+ // changes uncommitted — pushing now would silently drop them, and the
510
+ // agent would report a "successful push" of code the hook had expected
511
+ // to be included.
512
+ const postHookStatus = $("git", ["status", "--porcelain"], {
513
+ log: false,
514
+ });
515
+ if (postHookStatus) {
516
+ throw new Error(
517
+ `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` +
518
+ `git status:\n${postHookStatus}`,
519
+ );
520
+ }
521
+ }
522
+
523
+ log.debug(
524
+ `pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`,
525
+ );
526
+ if (force) {
527
+ log.warning(`force pushing - this will overwrite remote history`);
528
+ }
529
+
530
+ // push is idempotent, so pushWithRetry rides out transient failures
531
+ // (5xx, reset, freshly-minted-token 401). concurrent-push is not
532
+ // transient — it surfaces here so we can render the integrate-and-retry
533
+ // recovery the agent needs.
534
+ try {
535
+ await pushWithRetry(
536
+ pushArgs,
537
+ ctx.gitToken,
538
+ ctx.payload.shell !== "enabled",
539
+ ctx.refreshGitToken,
540
+ );
541
+ } catch (err) {
542
+ const msg = err instanceof Error ? err.message : String(err);
543
+ if (classifyPushError(msg) === "concurrent-push") {
544
+ // git rebase is blocked through the MCP tool when shell is disabled
545
+ // (rebase --exec can execute arbitrary code). merge always works and
546
+ // integrates remote changes cleanly, so suggest it as the default.
547
+ const integrateStep =
548
+ ctx.payload.shell === "disabled"
549
+ ? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
550
+ : `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
551
+ throw new Error(
552
+ `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` +
553
+ `to resolve this:\n` +
554
+ `1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
555
+ `${integrateStep}\n` +
556
+ `3. resolve any merge conflicts if needed\n` +
557
+ `4. retry push_branch`,
558
+ );
559
+ }
560
+ throw err;
561
+ }
562
+
563
+ const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
564
+ log.info(
565
+ `» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`,
566
+ );
567
+
568
+ const baseMsg = `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`;
569
+ const message = prepushSkipped
570
+ ? `${baseMsg} (prepush hook skipped — failed earlier this run).`
571
+ : baseMsg;
572
+
573
+ return {
574
+ success: true,
575
+ branch,
576
+ remoteBranch: pushDest.remoteBranch,
577
+ remote: pushDest.remoteName,
578
+ force,
579
+ prepushSkipped,
580
+ message,
581
+ };
582
+ }),
583
+ });
584
+ }
585
+
586
+ /** agent-facing prepush failure message: script output + bypass guidance,
587
+ * with no generic lifecycle retry advice (which would conflict). */
588
+ function buildPrepushFailureMessage(
589
+ failure: LifecycleHookFailure,
590
+ shell: GitToolContext["payload"]["shell"],
591
+ ): string {
592
+ const header =
593
+ failure.kind === "exit"
594
+ ? `prepush hook failed with exit code ${failure.exitCode}.\n\nscript output:\n${failure.output || "(empty)"}`
595
+ : failure.kind === "timeout"
596
+ ? `prepush hook timed out — the script is hung or doing too much work.`
597
+ : `prepush hook failed to spawn: ${failure.spawnError}.`;
598
+
599
+ const ifRealBug =
600
+ shell === "disabled"
601
+ ? `fix it before pushing again — shell access is disabled in this run, so you can't re-run the hook command yourself.`
602
+ : `run the hook command yourself via the shell tool to iterate (push_branch will NOT re-run it).`;
603
+
604
+ return (
605
+ `${header}\n\n` +
606
+ `this repo's prepush hook is best-effort: the next push_branch call will SKIP the hook and proceed. ` +
607
+ `if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call push_branch again. ` +
608
+ `if it could be a real bug in your code, ${ifRealBug}`
609
+ );
610
+ }
611
+
612
+ // commands that require authentication - redirect to dedicated tools.
613
+ // exported so tests can exercise the same table the runtime uses.
614
+ //
615
+ // note: the `pull` redirect intentionally does not mention `rebase` — under
616
+ // shell=disabled/restricted rebase is itself blocked by
617
+ // NOSHELL_BLOCKED_SUBCOMMANDS, so advertising it here would just send the
618
+ // agent into a second block. agents under shell=enabled who prefer rebase can
619
+ // invoke it directly; the redirect's job is to name the canonical alternative
620
+ // (merge), which works in all modes.
621
+ export const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
622
+ push: "use the push_branch tool instead — it handles authentication and permission checks.",
623
+ fetch: "use the git_fetch tool instead — it handles authentication.",
624
+ pull: "use git_fetch to fetch the remote ref, then call this git tool with command 'merge' locally.",
625
+ clone: "the repository is already cloned. use checkout_pr for PR branches.",
626
+ };
627
+
628
+ // SECURITY: subcommands blocked unless shell is "enabled" (full operator trust).
629
+ // these subcommands are the primary escape vectors for arbitrary code execution.
630
+ // they must be blocked in RESTRICTED mode too, not just disabled: the restricted
631
+ // shell tool runs its children under `unshare --pid --fork --mount-proc` with a
632
+ // stripped env, but this git tool's `$` is a bare spawnSync in the parent node
633
+ // process's PID namespace — a `rebase --exec`/config-driver child spawned here
634
+ // can read /proc/<node-pid>/environ, which still holds live credentials
635
+ // (INPUT_TOKEN, GITHUB_TOKEN, the installation token, the model key). "the
636
+ // agent already has shell" is therefore NOT an equivalence argument in
637
+ // restricted mode; it only holds in enabled mode, where the operator granted
638
+ // unsandboxed shell anyway. blocking here also keeps `git config` from writing
639
+ // hook/filter/alias drivers into the real .git/config, which would otherwise
640
+ // fire unsandboxed in downstream workflow steps.
641
+ // exported so tests stay in sync with the runtime table.
642
+ export const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
643
+ config:
644
+ "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
645
+ submodule:
646
+ "Blocked: git submodule can reference malicious repositories and execute code on update.",
647
+ "update-index":
648
+ "Blocked: git update-index can modify index entries in ways that bypass file protections.",
649
+ "filter-branch":
650
+ "Blocked: git filter-branch executes arbitrary code on repository history.",
651
+ replace: "Blocked: git replace can redirect object lookups.",
652
+ // subcommands that accept --exec or similar flags for arbitrary code execution
653
+ rebase:
654
+ "Blocked: git rebase --exec can execute arbitrary shell commands. Use 'merge' instead to integrate remote changes.",
655
+ bisect:
656
+ "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.",
657
+ // difftool/mergetool exist to shell out to external diff/merge programs.
658
+ // both accept `--extcmd` / `-x` (difftool) or configured tool commands
659
+ // (mergetool) that run arbitrary code. NOSHELL_BLOCKED_ARGS catches the
660
+ // long `--extcmd` form, but not the `-x` short form — and globally blocking
661
+ // `-x` would false-positive on `git cherry-pick -x`. block the subcommands
662
+ // wholesale instead; neither has a meaningful use in an automated agent
663
+ // workflow (agents use `git diff` / `git show` for diffs and resolve
664
+ // conflicts via file edits, not a TUI merge tool).
665
+ difftool:
666
+ "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.",
667
+ mergetool:
668
+ "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.",
669
+ };
670
+
671
+ // SECURITY: subcommand-specific arg flags that execute code.
672
+ // blocked unless shell is "enabled" — see the rationale on
673
+ // NOSHELL_BLOCKED_SUBCOMMANDS above (the restricted shell sandbox does not
674
+ // extend to this tool's spawnSync, so these flags would run unsandboxed).
675
+ //
676
+ // NOTE: global git flags like -c and --config-env are NOT included here
677
+ // because they only work before the subcommand. in the MCP tool, the
678
+ // subcommand is always first, so -c in args is parsed as a subcommand flag
679
+ // (e.g., git log -c = combined diff format), not config injection.
680
+ // the subcommand check (rejecting "-" prefix) already blocks that attack.
681
+ //
682
+ // matched as: arg === flag OR arg starts with flag + "="
683
+ // (avoids false positives like --exclude matching --exec).
684
+ // exported so tests stay in sync with the runtime flag set.
685
+ export const NOSHELL_BLOCKED_ARGS = [
686
+ "--exec",
687
+ "--extcmd",
688
+ "--upload-pack",
689
+ "--receive-pack",
690
+ ];
691
+
692
+ // SECURITY: arg flags that make git write an arbitrary file natively
693
+ // (`git diff --output=<path>`, `git log --output <path>`, `format-patch -o<dir>`).
694
+ // these bypass every write gate — the pretool write block and the read-only
695
+ // .git bind — so they get the same not-"enabled" gate as the code-exec flags.
696
+ // `-o` is matched as a PREFIX (git accepts the attached form `-o<path>`);
697
+ // double-dash flags like `--oneline`/`--orphan` don't match a single-dash
698
+ // prefix, so the only collateral is short-form `-o` itself (e.g. `git commit
699
+ // -o` — use the long form `--only` if genuinely needed).
700
+ // exported so tests stay in sync with the runtime flag set.
701
+ export const NOSHELL_BLOCKED_WRITE_ARGS = ["--output"];
702
+
703
+ /** true when `arg` is a native-file-write flag: `--output`, `--output=<path>`,
704
+ * `-o`, or attached `-o<path>`. */
705
+ export function isBlockedWriteArg(arg: string): boolean {
706
+ if (
707
+ NOSHELL_BLOCKED_WRITE_ARGS.some(
708
+ (flag) => arg === flag || arg.startsWith(`${flag}=`),
709
+ )
710
+ ) {
711
+ return true;
712
+ }
713
+ return arg.startsWith("-o") && !arg.startsWith("--");
714
+ }
715
+
716
+ const COLLAPSE_THRESHOLD = 200;
717
+
718
+ /** above this, the full body is spilled to a tmp file and only a short head
719
+ * preview is logged + returned inline. mirrors `capOutput` in `mcp/shell.ts`
720
+ * (which uses 5000 for shell commands; diff/log outputs need more headroom).
721
+ * the operator log gets a single summary line instead of a 1000+ line dump. */
722
+ const MAX_GIT_OUTPUT_CHARS = 50_000;
723
+ const OVERFLOW_PREVIEW_LINES = 50;
724
+ /** absolute char cap on the inline preview, in case the first
725
+ * `OVERFLOW_PREVIEW_LINES` lines contain a minified blob / binary diff /
726
+ * single very long line that would blow the agent's context anyway. */
727
+ const OVERFLOW_PREVIEW_MAX_CHARS = 5_000;
728
+
729
+ /** detect refs in `git diff` args that would produce a symmetric (two-dot)
730
+ * diff including the inverse of commits that landed on `<ref>` since the
731
+ * branch forked. returns the offending arg + the ref that's ahead + count of
732
+ * unmerged commits, or null if the call is safe. silently ignores args that
733
+ * aren't refs (paths, pathspecs), three-dot ranges (those are merge-base
734
+ * diffs, the correct shape), and any call passing `--merge-base` (git's own
735
+ * shorthand for a merge-base diff, also safe). see [run 26545933188](https://github.com/infraweaver/app/actions/runs/26545933188)
736
+ * for the failure mode this guards against. */
737
+ function detectSymmetricDiffTrap(
738
+ args: string[],
739
+ ): { arg: string; aheadRef: string; ahead: number } | null {
740
+ // git's own `--merge-base` flag (2.30+) produces a safe merge-base diff
741
+ // regardless of the positional ref; the GHA runner has git 2.54.x.
742
+ if (args.includes("--merge-base")) return null;
743
+ // ignore everything after `--` (pathspec separator)
744
+ const endIdx = args.indexOf("--");
745
+ const positionals = (endIdx === -1 ? args : args.slice(0, endIdx)).filter(
746
+ (a) => !a.startsWith("-"),
747
+ );
748
+ for (const p of positionals) {
749
+ if (p.includes("...")) continue; // three-dot = merge-base diff, safe
750
+ // bare ref `A`: implicit second side is HEAD; agent's intent is
751
+ // "what my branch changed vs <ref>". fires when <ref> has commits HEAD
752
+ // doesn't (branch behind base). diffs against an ancestor (HEAD ahead
753
+ // of <ref>) are the legitimate "what did I add since X" case and must
754
+ // not be blocked.
755
+ //
756
+ // two-dot range `A..B`: degenerate when one side is an ancestor of the
757
+ // other (the tree diff equals the merge-base diff — safe). only the
758
+ // truly-diverged case (BOTH sides have commits the other lacks) pulls
759
+ // unwanted inverse-of-progress into the diff. shorthand expansions:
760
+ // `A..` → `A..HEAD`, `..A` → `HEAD..A`.
761
+ if (p.includes("..")) {
762
+ const parts = p.split("..");
763
+ if (parts.length !== 2) continue;
764
+ const left = parts[0] || "HEAD";
765
+ const right = parts[1] || "HEAD";
766
+ const leftAhead = countAhead(right, left);
767
+ const rightAhead = countAhead(left, right);
768
+ if (leftAhead === null || rightAhead === null) continue;
769
+ if (leftAhead > 0 && rightAhead > 0) {
770
+ const aheadRef = leftAhead >= rightAhead ? left : right;
771
+ return { arg: p, aheadRef, ahead: Math.max(leftAhead, rightAhead) };
772
+ }
773
+ continue;
774
+ }
775
+ const ahead = countAhead("HEAD", p);
776
+ if (ahead === null) continue;
777
+ if (ahead > 0) return { arg: p, aheadRef: p, ahead };
778
+ }
779
+ return null;
780
+ }
781
+
782
+ /** `rev-list --count head..base` = commits on `base` not on `head`. returns
783
+ * null if either ref is unresolvable (probably a pathspec). */
784
+ function countAhead(head: string, base: string): number | null {
785
+ try {
786
+ const out = $("git", ["rev-list", "--count", `${head}..${base}`], {
787
+ log: false,
788
+ }).trim();
789
+ const n = parseInt(out, 10);
790
+ return Number.isFinite(n) ? n : null;
791
+ } catch {
792
+ return null;
793
+ }
794
+ }
795
+
796
+ /** persist `output` to a tmp file and return an agent-facing string that
797
+ * leads with a head preview and ends with a sentinel pointing at the path.
798
+ * keeps the operator log to a single summary line for the overflow case. */
799
+ function spillGitOutput(params: {
800
+ command: string;
801
+ args: string[];
802
+ output: string;
803
+ lineCount: number;
804
+ }): { output: string; outputPath: string } {
805
+ const tempDir = getTempDir();
806
+ const outputPath = join(
807
+ tempDir,
808
+ `git-${params.command}-${randomUUID().slice(0, 8)}.txt`,
809
+ );
810
+ writeFileSync(outputPath, params.output);
811
+ const previewByLines = params.output
812
+ .split("\n")
813
+ .slice(0, OVERFLOW_PREVIEW_LINES)
814
+ .join("\n");
815
+ const preview =
816
+ previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS
817
+ ? previewByLines
818
+ : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}…`;
819
+ log.info(
820
+ `» git ${params.command} ${params.args.join(" ")}: ${params.lineCount} lines / ${params.output.length} chars → ${outputPath}`,
821
+ );
822
+ return {
823
+ output: `${preview}\n\n... [output truncated; full ${params.lineCount}-line / ${params.output.length}-char body saved to ${outputPath} — read selectively with \`read({ filePath: "${outputPath}" })\`] ...`,
824
+ outputPath,
825
+ };
826
+ }
827
+
828
+ // SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
829
+ // this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
830
+ //
831
+ // critical attack: git -c "alias.x=!evil-command" x
832
+ // -> sets alias "x" to a shell command via -c config injection, then runs it
833
+ // -> achieves arbitrary code execution even with shell=disabled
834
+ const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
835
+
836
+ const Git = type({
837
+ command: type(subcommandPattern).describe(
838
+ "Git command (e.g., 'status', 'log', 'diff')",
839
+ ),
840
+ args: type.string
841
+ .array()
842
+ .describe("Additional arguments for the git command")
843
+ .optional(),
844
+ });
845
+
846
+ export function GitTool(ctx: GitToolContext) {
847
+ return tool({
848
+ name: "git",
849
+ description:
850
+ "Run a git subcommand. `command` is the subcommand ONLY — never repeat it inside `args`. " +
851
+ "`args` is optional; omit it entirely for no-flag invocations like plain `git status`. " +
852
+ 'Example: `git({ command: "status" })` for plain `git status`. ' +
853
+ 'Example: `git({ command: "log", args: ["--oneline", "-n", "20"] })`. ' +
854
+ 'Example: `git({ command: "diff", args: ["--merge-base", "origin/main"] })` — merge-base diff including uncommitted edits (single MCP call). ' +
855
+ 'Example: `git({ command: "diff", args: ["origin/main...HEAD"] })` — three-dot, committed-only changes vs merge-base. ' +
856
+ "For PR-scope diffs ALWAYS use `--merge-base <base>` or three-dot `<base>...HEAD`. " +
857
+ "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. " +
858
+ "Output >50K chars is spilled to a tmp file; the tool returns a head preview + path you can `read` selectively. " +
859
+ "For push/fetch, use the dedicated MCP tools (push_branch, git_fetch). " +
860
+ "git pull is not available — use git_fetch then this tool with command 'merge'.",
861
+ parameters: Git,
862
+ execute: execute(async (params) => {
863
+ const command = params.command;
864
+ const args = params.args ?? [];
865
+
866
+ // guard: {command:"status",args:["status"]} → `git status status`, where
867
+ // git silently treats args[0] as a pathspec. when nothing matches the
868
+ // path, status prints "nothing to commit, working tree clean" even on a
869
+ // dirty tree — a real model failure mode that burned a ~$3 run before
870
+ // self-correction. generalises to every subcommand (`diff diff`,
871
+ // `log log`, etc.).
872
+ if (args[0]?.toLowerCase() === command.toLowerCase()) {
873
+ throw new Error(
874
+ `git ${command}: '${args[0]}' duplicates the subcommand — drop args[0] ` +
875
+ `(the subcommand only belongs in 'command'). git would otherwise parse it as ` +
876
+ `a pathspec and silently return empty/clean output when nothing matches. ` +
877
+ `if you really meant a pathspec named '${args[0]}', use args: ["--", "${args[0]}"].`,
878
+ );
879
+ }
880
+
881
+ const redirect = AUTH_REQUIRED_REDIRECT[command];
882
+ if (redirect) {
883
+ throw new Error(
884
+ `git ${command} is not available through this tool — ${redirect}`,
885
+ );
886
+ }
887
+
888
+ // SECURITY: block code-executing subcommands/flags unless shell is
889
+ // "enabled". this applies in RESTRICTED mode too: the restricted shell
890
+ // tool sandboxes its children (`unshare --pid --mount-proc`, stripped
891
+ // env), but this tool's `$` is a bare spawnSync in the parent process's
892
+ // PID namespace — a child spawned via `rebase --exec` or a config-driven
893
+ // hook could read /proc/<node-pid>/environ and exfiltrate the live
894
+ // tokens the action process still holds. see NOSHELL_BLOCKED_SUBCOMMANDS.
895
+ if (ctx.payload.shell !== "enabled") {
896
+ const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[command];
897
+ if (blocked) {
898
+ const hint =
899
+ ctx.payload.shell === "restricted"
900
+ ? " If you genuinely need this, run it via the sandboxed shell tool instead."
901
+ : "";
902
+ throw new Error(`${blocked}${hint}`);
903
+ }
904
+
905
+ // block subcommand-specific flags that execute arbitrary code
906
+ for (const arg of args) {
907
+ const isBlocked = NOSHELL_BLOCKED_ARGS.some(
908
+ (flag) => arg === flag || arg.startsWith(`${flag}=`),
909
+ );
910
+ if (isBlocked) {
911
+ throw new Error(
912
+ `Blocked: '${arg}' flag can execute arbitrary code and is not allowed.`,
913
+ );
914
+ }
915
+ // block native-file-write flags (`--output`, `-o<path>`) — they
916
+ // bypass the write gates and the read-only .git bind.
917
+ if (isBlockedWriteArg(arg)) {
918
+ throw new Error(
919
+ `Blocked: '${arg}' makes git write an arbitrary file, bypassing write protections, and is not allowed.`,
920
+ );
921
+ }
922
+ }
923
+ }
924
+
925
+ // reject symmetric (two-dot or bare-ref) diffs whose endpoints have
926
+ // commits each other doesn't — those include the *inverse* of every
927
+ // commit on the diverged side, ballooning the diff and confusing
928
+ // reviewer subagents. three-dot (`A...B`) and `--merge-base` are
929
+ // always allowed (both produce merge-base diffs).
930
+ if (command === "diff") {
931
+ const trap = detectSymmetricDiffTrap(args);
932
+ if (trap) {
933
+ throw new Error(
934
+ `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` +
935
+ `use one of:\n` +
936
+ ` - git diff --merge-base ${trap.aheadRef} (one MCP call; merge-base diff, includes uncommitted edits)\n` +
937
+ ` - git diff ${trap.aheadRef}...HEAD (three-dot; merge-base diff of committed-only changes)\n\n` +
938
+ `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.`,
939
+ );
940
+ }
941
+ }
942
+
943
+ // `git merge-base --is-ancestor` uses exit codes as data: 0 = ancestor,
944
+ // 1 = not-an-ancestor, >1 = real error. Surface the binary answer
945
+ // instead of throwing on exit 1. see #766.
946
+ if (command === "merge-base" && args.includes("--is-ancestor")) {
947
+ let isAncestor = true;
948
+ $("git", [command, ...args], {
949
+ log: false,
950
+ onError: (r) => {
951
+ if (r.status === 1) {
952
+ isAncestor = false;
953
+ return;
954
+ }
955
+ const detail = [r.stderr, r.stdout]
956
+ .map((s) => s.trim())
957
+ .filter(Boolean)
958
+ .join("\n");
959
+ throw new Error(
960
+ `git merge-base --is-ancestor failed (exit ${r.status}): ${detail || "Unknown error"}`,
961
+ );
962
+ },
963
+ });
964
+ return { success: true, isAncestor };
965
+ }
966
+
967
+ const output = $("git", [command, ...args], { log: false });
968
+ const lineCount = output.split("\n").length;
969
+ if (output.length > MAX_GIT_OUTPUT_CHARS) {
970
+ const spilled = spillGitOutput({ command, args, output, lineCount });
971
+ return {
972
+ success: true,
973
+ output: spilled.output,
974
+ outputPath: spilled.outputPath,
975
+ };
976
+ }
977
+ if (lineCount > COLLAPSE_THRESHOLD) {
978
+ log.group(`git ${command} output (${lineCount} lines)`, () => {
979
+ log.info(output);
980
+ });
981
+ } else if (output) {
982
+ log.info(output);
983
+ }
984
+
985
+ return { success: true, output };
986
+ }),
987
+ });
988
+ }
989
+
990
+ const GitFetch = type({
991
+ ref: type.string.describe(
992
+ "Ref to fetch: branch name, tag, or 'pull/N/head' for PRs",
993
+ ),
994
+ depth: type.number.describe("Fetch depth (for shallow clones)").optional(),
995
+ });
996
+
997
+ export function GitFetchTool(ctx: GitToolContext) {
998
+ return tool({
999
+ name: "git_fetch",
1000
+ description:
1001
+ "Fetch refs from remote repository. Use this instead of git fetch directly. " +
1002
+ 'Example: `git_fetch({ ref: "main" })`. With depth: `git_fetch({ ref: "pull/1234/head", depth: 1 })`.',
1003
+ parameters: GitFetch,
1004
+ execute: execute(async (params) => {
1005
+ rejectIfLeadingDash(params.ref, "ref");
1006
+ const fetchArgs = ["--no-tags", "origin", params.ref];
1007
+ if (params.depth !== undefined) {
1008
+ fetchArgs.push(`--depth=${params.depth}`);
1009
+ }
1010
+ await $gitFetchWithDeepen(
1011
+ fetchArgs,
1012
+ { token: ctx.gitToken },
1013
+ "git_fetch",
1014
+ );
1015
+ return { success: true, ref: params.ref };
1016
+ }),
1017
+ });
1018
+ }
1019
+
1020
+ const DeleteBranch = type({
1021
+ branchName: type.string.describe("Remote branch to delete"),
1022
+ });
1023
+
1024
+ export function DeleteBranchTool(ctx: GitToolContext) {
1025
+ const pushPermission = ctx.payload.push;
1026
+ const defaultBranch = ctx.repo.data.default_branch || "main";
1027
+
1028
+ return tool({
1029
+ mutates: true,
1030
+ name: "delete_branch",
1031
+ description:
1032
+ "Delete a remote branch. Requires push: enabled permission. " +
1033
+ "Deletion of the repository's default branch is always blocked regardless of permission mode.",
1034
+ parameters: DeleteBranch,
1035
+ execute: execute(async (params) => {
1036
+ if (pushPermission !== "enabled") {
1037
+ throw new Error(
1038
+ "Branch deletion requires push: enabled permission. " +
1039
+ "Current mode only allows pushing to non-protected branches.",
1040
+ );
1041
+ }
1042
+
1043
+ // delete_branch is already gated on push: enabled, but also block the
1044
+ // refs/heads/... and symbolic-ref forms so this tool can't be tricked
1045
+ // into deleting a protected ref that wouldn't match a bare-name check.
1046
+ rejectSpecialRef(params.branchName, "branchName");
1047
+
1048
+ // defense-in-depth: deleting the default branch is catastrophic and
1049
+ // unlike pushing to main it has no easy revert path (GitHub retains
1050
+ // refs for 30 days but restoring requires the reflog or a direct SHA).
1051
+ // push: enabled authorizes pushes, not wholesale removal of the
1052
+ // repository's primary branch. block it locally even if GitHub branch
1053
+ // protection would also reject — some repos disable protection on
1054
+ // default branches and we should not rely on that config for safety.
1055
+ if (params.branchName === defaultBranch) {
1056
+ throw new Error(
1057
+ `Blocked: cannot delete the default branch '${defaultBranch}'. ` +
1058
+ `If you really need to delete or rename it, do it manually via the repository settings.`,
1059
+ );
1060
+ }
1061
+
1062
+ // use refs/heads/<name> explicitly so a same-named tag can't be deleted
1063
+ // by accident. `push --delete <bare-name>` resolves against both remote
1064
+ // branches and tags; a tag-only match would silently remove the tag.
1065
+ // rejectSpecialRef guarantees branchName is a bare name, so the
1066
+ // branchName construction here can't collide with user-supplied refs.
1067
+ await pushWithRetry(
1068
+ ["origin", "--delete", `refs/heads/${params.branchName}`],
1069
+ ctx.gitToken,
1070
+ ctx.payload.shell !== "enabled",
1071
+ ctx.refreshGitToken,
1072
+ );
1073
+ log.info(`» deleted branch ${params.branchName}`);
1074
+ return { success: true, deleted: params.branchName };
1075
+ }),
1076
+ });
1077
+ }
1078
+
1079
+ const PushTags = type({
1080
+ tag: type.string.describe("Tag name to push"),
1081
+ force: type.boolean.describe("Force push the tag").default(false),
1082
+ });
1083
+
1084
+ export function PushTagsTool(ctx: GitToolContext) {
1085
+ const pushPermission = ctx.payload.push;
1086
+
1087
+ return tool({
1088
+ mutates: true,
1089
+ name: "push_tags",
1090
+ description: "Push a tag to remote. Requires push: enabled permission.",
1091
+ parameters: PushTags,
1092
+ execute: execute(async (params) => {
1093
+ if (pushPermission !== "enabled") {
1094
+ throw new Error(
1095
+ "Tag pushing requires push: enabled permission. " +
1096
+ "Current mode only allows pushing branches.",
1097
+ );
1098
+ }
1099
+
1100
+ validateTagName(params.tag);
1101
+ const pushArgs = [
1102
+ ...(params.force ? ["-f"] : []),
1103
+ "origin",
1104
+ `refs/tags/${params.tag}`,
1105
+ ];
1106
+ await pushWithRetry(
1107
+ pushArgs,
1108
+ ctx.gitToken,
1109
+ ctx.payload.shell !== "enabled",
1110
+ ctx.refreshGitToken,
1111
+ );
1112
+ log.info(`» pushed tag ${params.tag}`);
1113
+ return { success: true, tag: params.tag };
1114
+ }),
1115
+ });
1116
+ }