terramend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,1246 @@
1
+ /**
2
+ * Claude Code agent — secure harness around the `claude` CLI.
3
+ *
4
+ * mirrors the opencode harness's security model:
5
+ * - native exec tools (Bash, Monitor, REPL, Workflow) blocked via BOTH
6
+ * --disallowedTools AND managed-settings.json `permissions.deny` (the agent
7
+ * cannot shell out / run code outside the MCP shell). the managed-settings
8
+ * deny is the authoritative, bypass-immune layer: `--disallowedTools` alone
9
+ * (a `cliArg`-source deny) was observed to leak under
10
+ * `--dangerously-skip-permissions`, surfacing a secret env marker via the
11
+ * native Bash tool. managed-settings denies are `policySettings`-source,
12
+ * highest precedence, and survive bypassPermissions mode.
13
+ * - managed-settings.json: filesystem sandbox — deny /proc, /sys reads
14
+ * - MCP ShellTool provides restricted shell (filtered env, no secrets)
15
+ * - MCP server injected via --mcp-config (not replacing project config)
16
+ * - ASKPASS handles git auth separately (token never in subprocess env)
17
+ *
18
+ * the agent process itself gets full env (needs LLM API keys, PATH, etc.).
19
+ * security is enforced at the tool layer, not the process layer.
20
+ */
21
+ import { execFileSync } from "node:child_process";
22
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { performance } from "node:perf_hooks";
25
+ import {
26
+ buildClaudePretoolGateSettings,
27
+ CLAUDE_PRETOOL_GATE_FILENAME,
28
+ CLAUDE_PRETOOL_GATE_SOURCE,
29
+ } from "#app/agents/claudePretoolGate";
30
+ import { startGateServer } from "#app/agents/gateServer";
31
+ import {
32
+ GIT_NATIVE_READ_DENY_CLAUDE,
33
+ GIT_NATIVE_WRITE_DENY_CLAUDE,
34
+ } from "#app/agents/nativeFsDenies";
35
+ import { finalizeAgentResult } from "#app/agents/postRun";
36
+ import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "#app/agents/reviewer";
37
+ import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "#app/agents/sessionLabeler";
38
+ import {
39
+ type AgentResult,
40
+ type AgentRunContext,
41
+ type AgentUsage,
42
+ agent,
43
+ logTokenTable,
44
+ MAX_STDERR_LINES,
45
+ MCP_SERVER_TOKEN_ENV,
46
+ } from "#app/agents/shared";
47
+ import { terramendMcpName } from "#app/external";
48
+ import {
49
+ BEDROCK_MODEL_ID_ENV,
50
+ isBedrockAnthropicId,
51
+ isVertexAnthropicId,
52
+ VERTEX_MODEL_ID_ENV,
53
+ } from "#app/models";
54
+ import { AGENT_ACTIVITY_TIMEOUT_MS, getIdleMs, markActivity } from "#app/utils/activity";
55
+ import { preflightClaudeSubscription } from "#app/utils/claudeSubscription";
56
+ import { formatJsonValue, log } from "#app/utils/cli";
57
+ import { installFromNpmTarball } from "#app/utils/install";
58
+ import { findProviderErrorMatch } from "#app/utils/providerErrors";
59
+ import { installBundledSkills } from "#app/utils/skills";
60
+ import {
61
+ DEFAULT_MAX_RETAINED_BYTES,
62
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
63
+ SpawnTimeoutError,
64
+ spawn,
65
+ TailBuffer,
66
+ } from "#app/utils/subprocess";
67
+ import { resolveTerraformMcp, TERRAFORM_MCP_SERVER_NAME } from "#app/utils/terraformMcp";
68
+ import { ThinkingTimer } from "#app/utils/timer";
69
+ import type { TodoTracker } from "#app/utils/todoTracking";
70
+ import { getDevDependencyVersion } from "#app/utils/version";
71
+ import { applyClaudeVertexEnv } from "#app/utils/vertex";
72
+
73
+ async function installClaudeCli(): Promise<string> {
74
+ return await installFromNpmTarball({
75
+ packageName: "@anthropic-ai/claude-code",
76
+ version: getDevDependencyVersion("@anthropic-ai/claude-code"),
77
+ // 2.1.113+ ships a native binary (bin/claude.exe) instead of cli.js; the
78
+ // package postinstall copies it from the platform optionalDependency, so we
79
+ // need installDependencies to run that postinstall.
80
+ executablePath: "bin/claude.exe",
81
+ installDependencies: true,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Native claude-code tools that execute arbitrary shell/code and therefore
87
+ * bypass Terramend's security boundary (the restricted MCP `shell` tool with a
88
+ * filtered, secret-free env). These run inside the agent process with full env,
89
+ * so leaving any of them enabled defeats both `shell: "disabled"` AND the
90
+ * env-filtering that the MCP shell relies on even when shell is enabled.
91
+ *
92
+ * As of claude-code 2.1.150 the exec surface is no longer just `Bash`:
93
+ * - `Monitor` runs a shell command/script (the `command` field)
94
+ * - `REPL` runs arbitrary JavaScript (can `require("node:child_process")`)
95
+ * - `Workflow` orchestrates subagents/pipelines that can reach the above
96
+ * Each is denied at top level and inside `Agent(...)` (Task subagents), mirroring
97
+ * the existing `Bash` / `Agent(Bash)` pair. Denying a tool that isn't registered
98
+ * in a given run is a harmless no-op, so this list is also forward-safe.
99
+ *
100
+ * `CLAUDE_EXEC_TOOL_DENY_RULES` is wired into TWO surfaces: `--disallowedTools`
101
+ * (removes the tools from the advertised list) and managed-settings.json
102
+ * `permissions.deny` (the authoritative, bypass-immune deny — see
103
+ * buildManagedSettings). The flag alone proved insufficient: under
104
+ * `--dangerously-skip-permissions` the native Bash tool ran despite
105
+ * `--disallowedTools Bash`, leaking a per-run secret marker.
106
+ */
107
+ const CLAUDE_EXEC_TOOLS = ["Bash", "Monitor", "REPL", "Workflow"] as const;
108
+ export const CLAUDE_EXEC_TOOL_DENY_RULES = [
109
+ ...CLAUDE_EXEC_TOOLS,
110
+ ...CLAUDE_EXEC_TOOLS.map((t) => `Agent(${t})`),
111
+ ];
112
+ const CLAUDE_DISALLOWED_TOOLS = CLAUDE_EXEC_TOOL_DENY_RULES.join(",");
113
+
114
+ // ── config ─────────────────────────────────────────────────────────────────────
115
+
116
+ // Claude Code expands `${VAR}` in .mcp.json values, including HTTP-server
117
+ // `headers` (code.claude.com/docs/en/mcp-configuration "Environment variable
118
+ // expansion"), so the on-disk mcp.json carries only this placeholder — never the
119
+ // raw token, which travels via MCP_SERVER_TOKEN_ENV on the agent's spawn env.
120
+ const TERRAMEND_MCP_AUTH_HEADER = `Bearer \${${MCP_SERVER_TOKEN_ENV}}`;
121
+
122
+ export function writeMcpConfig(ctx: AgentRunContext): string {
123
+ const configDir = join(ctx.tmpdir, ".claude");
124
+ mkdirSync(configDir, { recursive: true });
125
+ const configPath = join(configDir, "mcp.json");
126
+ // P2.2 — opt-in second server: HashiCorp's terraform-mcp-server (registry
127
+ // toolset, docker stdio) for live module/provider knowledge.
128
+ const terraformMcp = resolveTerraformMcp(ctx.payload);
129
+ if (terraformMcp.kind === "docker_missing") log.info(`» ${terraformMcp.note}`);
130
+ writeFileSync(
131
+ configPath,
132
+ JSON.stringify({
133
+ mcpServers: {
134
+ [terramendMcpName]: {
135
+ type: "http",
136
+ url: ctx.mcpServerUrl,
137
+ headers: { Authorization: TERRAMEND_MCP_AUTH_HEADER },
138
+ },
139
+ ...(terraformMcp.kind === "available"
140
+ ? {
141
+ [TERRAFORM_MCP_SERVER_NAME]: {
142
+ type: "stdio",
143
+ command: terraformMcp.command,
144
+ args: terraformMcp.args,
145
+ },
146
+ }
147
+ : {}),
148
+ },
149
+ }),
150
+ );
151
+ return configPath;
152
+ }
153
+
154
+ /**
155
+ * Drop the PreToolUse gate script + its `--settings` JSON into the per-run
156
+ * tmpdir and return the absolute path to the settings file. The script
157
+ * blocks state-mutating MCP tool calls when `agent_id` is non-empty (i.e.,
158
+ * the call originates inside a Task/Agent subagent dispatch). See
159
+ * action/agents/claudePretoolGate.ts for the contract.
160
+ *
161
+ * Two paths register the gate:
162
+ * 1. flag settings (`--settings <path>`) — covers non-CI runs (`pnpm dev:run`,
163
+ * local dev) where `installManagedSettings` is a no-op.
164
+ * 2. managed settings (/etc/claude-code/managed-settings.json) — covers CI,
165
+ * where `allowManagedHooksOnly: true` filters flag-settings hooks. The
166
+ * same hook entry is embedded in `buildManagedSettings` below.
167
+ *
168
+ * The flag settings also carry the native exec-tool `permissions.deny`
169
+ * (via `buildClaudePretoolGateSettings`) so non-CI runs (where managed
170
+ * settings are absent) still block native Bash et al. at a settings-source
171
+ * deny, not just the `--disallowedTools` cliArg deny that proved leaky under
172
+ * `--dangerously-skip-permissions`.
173
+ */
174
+ function writePretoolGateAssets(ctx: AgentRunContext): {
175
+ scriptPath: string;
176
+ settingsPath: string;
177
+ } {
178
+ const scriptPath = join(ctx.tmpdir, CLAUDE_PRETOOL_GATE_FILENAME);
179
+ writeFileSync(scriptPath, CLAUDE_PRETOOL_GATE_SOURCE);
180
+ chmodSync(scriptPath, 0o755);
181
+ const settingsPath = join(ctx.tmpdir, "terramend-claude-settings.json");
182
+ const settings = buildClaudePretoolGateSettings(scriptPath, CLAUDE_EXEC_TOOL_DENY_RULES);
183
+ writeFileSync(settingsPath, JSON.stringify(settings));
184
+ return { scriptPath, settingsPath };
185
+ }
186
+
187
+ /**
188
+ * Build the `--agents` JSON definition for the `reviewfrog` subagent.
189
+ *
190
+ * The Claude Code path always runs against an Anthropic model (see
191
+ * resolveAgent), so we hardcode the cheaper-sibling downshift: lenses run
192
+ * on Sonnet, the orchestrator stays on whatever model `--model` was passed.
193
+ *
194
+ * Per-call model override is also possible (Task tool's `model` arg accepts
195
+ * 'sonnet' | 'opus' | 'haiku') and takes precedence over what's set here —
196
+ * we don't pass it; the per-subagent `model` field is the right default.
197
+ *
198
+ * The non-mutative + non-recursive contract is enforced by the prose system
199
+ * prompt baked into the agent — see action/agents/reviewer.ts for why we
200
+ * no longer wire per-agent `disallowedTools` here.
201
+ */
202
+ export function buildAgentsJson(): string {
203
+ const agents = {
204
+ [REVIEWER_AGENT_NAME]: {
205
+ description:
206
+ "Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
207
+ "Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
208
+ prompt: REVIEWER_SYSTEM_PROMPT,
209
+ model: "claude-sonnet-4-6",
210
+ },
211
+ };
212
+ return JSON.stringify(agents);
213
+ }
214
+
215
+ // ── model helpers ─────────────────────────────────────────────────────────────
216
+
217
+ // claude CLI expects bare model names (e.g. "claude-sonnet-4-6"), not provider-prefixed specifiers
218
+ export function stripProviderPrefix(specifier: string): string {
219
+ const slashIndex = specifier.indexOf("/");
220
+ return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier;
221
+ }
222
+
223
+ // `high` is the model's tuned default ("equivalent to not setting the parameter"
224
+ // per Anthropic docs). `max` is "absolute maximum capability with no constraints
225
+ // on token spending" — meaningfully slower and burns more thinking budget per
226
+ // turn. We default everyone to `high`; PRs that genuinely need full-send can
227
+ // opt in via a future per-run override rather than paying the wall-time cost on
228
+ // every Opus run.
229
+ function resolveEffort(_model: string | undefined): "high" {
230
+ return "high";
231
+ }
232
+
233
+ // ── NDJSON event types ─────────────────────────────────────────────────────────
234
+
235
+ interface ContentBlock {
236
+ type: string;
237
+ text?: string;
238
+ id?: string;
239
+ name?: string;
240
+ input?: unknown;
241
+ tool_use_id?: string;
242
+ content?: string | unknown;
243
+ is_error?: boolean;
244
+ [key: string]: unknown;
245
+ }
246
+
247
+ // SDK schema (per claude-agent-sdk docs) puts `session_id` and
248
+ // `parent_tool_use_id` at the top level of every Assistant/User/System/Result
249
+ // message, not inside `message`. Subagent events carry a non-null
250
+ // `parent_tool_use_id` pointing at the orchestrator's Task/Agent tool_use id.
251
+ interface ClaudeSystemEvent {
252
+ type: "system";
253
+ session_id?: string;
254
+ parent_tool_use_id?: string | null;
255
+ [key: string]: unknown;
256
+ }
257
+
258
+ interface ClaudeAssistantEvent {
259
+ type: "assistant";
260
+ session_id?: string;
261
+ parent_tool_use_id?: string | null;
262
+ message?: {
263
+ role?: string;
264
+ content?: ContentBlock[];
265
+ model?: string;
266
+ usage?: {
267
+ input_tokens?: number;
268
+ output_tokens?: number;
269
+ cache_creation_input_tokens?: number;
270
+ cache_read_input_tokens?: number;
271
+ };
272
+ [key: string]: unknown;
273
+ };
274
+ [key: string]: unknown;
275
+ }
276
+
277
+ interface ClaudeUserEvent {
278
+ type: "user";
279
+ session_id?: string;
280
+ parent_tool_use_id?: string | null;
281
+ message?: {
282
+ role?: string;
283
+ content?: ContentBlock[];
284
+ [key: string]: unknown;
285
+ };
286
+ [key: string]: unknown;
287
+ }
288
+
289
+ interface ClaudeResultEvent {
290
+ type: "result";
291
+ subtype?: string;
292
+ // claude CLI sets `is_error: true` (alongside `subtype: "success"`) when
293
+ // an upstream provider fails mid-stream. `api_error_status` carries the
294
+ // provider HTTP status (e.g. 401 for invalid API key). per the official
295
+ // SDK types, `api_error_status` is `number | null`, and the `error_*`
296
+ // subtypes carry their actionable payload in `errors: string[]` instead
297
+ // of `result`.
298
+ is_error?: boolean;
299
+ api_error_status?: number | null;
300
+ errors?: string[];
301
+ result?: string;
302
+ session_id?: string;
303
+ num_turns?: number;
304
+ total_cost_usd?: number;
305
+ total_input_tokens?: number;
306
+ total_output_tokens?: number;
307
+ usage?: {
308
+ input_tokens?: number;
309
+ output_tokens?: number;
310
+ cache_read_input_tokens?: number;
311
+ cache_creation_input_tokens?: number;
312
+ };
313
+ [key: string]: unknown;
314
+ }
315
+
316
+ // additional event types emitted by Claude CLI (handled as no-ops / debug)
317
+ interface ClaudeStreamEvent {
318
+ type: "stream_event";
319
+ [key: string]: unknown;
320
+ }
321
+ interface ClaudeToolProgressEvent {
322
+ type: "tool_progress";
323
+ [key: string]: unknown;
324
+ }
325
+ interface ClaudeToolUseSummaryEvent {
326
+ type: "tool_use_summary";
327
+ [key: string]: unknown;
328
+ }
329
+ interface ClaudeAuthStatusEvent {
330
+ type: "auth_status";
331
+ [key: string]: unknown;
332
+ }
333
+
334
+ type ClaudeEvent =
335
+ | ClaudeSystemEvent
336
+ | ClaudeAssistantEvent
337
+ | ClaudeUserEvent
338
+ | ClaudeResultEvent
339
+ | ClaudeStreamEvent
340
+ | ClaudeToolProgressEvent
341
+ | ClaudeToolUseSummaryEvent
342
+ | ClaudeAuthStatusEvent;
343
+
344
+ // ── runner ──────────────────────────────────────────────────────────────────────
345
+
346
+ type RunParams = {
347
+ label: string;
348
+ cmd: string;
349
+ args: string[];
350
+ cwd: string;
351
+ env: Record<string, string | undefined>;
352
+ todoTracker?: TodoTracker | undefined;
353
+ onActivityTimeout?: (() => void) | undefined;
354
+ onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
355
+ };
356
+
357
+ type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
358
+
359
+ /**
360
+ * Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units,
361
+ * dropping any partial first line. used in the exit-non-zero stdout fallback
362
+ * so we never surface a truncated NDJSON event to operators —
363
+ * `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a
364
+ * syntactically broken JSON fragment. code units rather than bytes because
365
+ * `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8
366
+ * content the effective byte budget can be up to 4× the nominal limit.
367
+ */
368
+ export function tailLines(text: string, maxCodeUnits: number): string {
369
+ if (text.length <= maxCodeUnits) return text;
370
+ const tail = text.slice(-maxCodeUnits);
371
+ const firstNewline = tail.indexOf("\n");
372
+ // if no newline in window or it's at the very start, return as-is;
373
+ // otherwise drop the partial first line.
374
+ return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
375
+ }
376
+
377
+ export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
378
+ const startTime = performance.now();
379
+ let eventCount = 0;
380
+
381
+ // per-session labeler so parallel subagent log lines can be differentiated.
382
+ // claude-agent-sdk runs subagents inside the orchestrator's session — they
383
+ // share `session_id` — and stamps every subagent message with a non-null
384
+ // `parent_tool_use_id` pointing at the Agent tool_use that spawned them.
385
+ // we bind each Agent tool_use id to its dispatched label up front, then
386
+ // labelFor short-circuits to the direct mapping when parent_tool_use_id is
387
+ // set. orchestrator events (parent_tool_use_id === null) flow through the
388
+ // sessionID path and bind to ORCHESTRATOR_LABEL on first sighting.
389
+ const labeler = new SessionLabeler();
390
+ function eventLabel(event: { session_id?: string; parent_tool_use_id?: string | null }): string {
391
+ return labeler.labelFor(event.session_id ?? null, event.parent_tool_use_id ?? null);
392
+ }
393
+ function withLabel(label: string, message: string): string {
394
+ return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
395
+ }
396
+
397
+ // one ThinkingTimer per session — sharing a single timer across sessions
398
+ // conflated cross-session interleaving as parent thinking time. each timer
399
+ // formats its log lines through the session label so attribution is visible.
400
+ const thinkingTimers = new Map<string, ThinkingTimer>();
401
+ function timerFor(label: string): ThinkingTimer {
402
+ let t = thinkingTimers.get(label);
403
+ if (!t) {
404
+ const formatLine = (line: string) =>
405
+ label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
406
+ t = new ThinkingTimer(formatLine);
407
+ thinkingTimers.set(label, t);
408
+ }
409
+ return t;
410
+ }
411
+
412
+ let finalOutput = "";
413
+ let sessionId: string | undefined;
414
+ let resultErrorSubtype: string | null = null;
415
+ // captures the structured error string from a result event with
416
+ // `is_error: true` (e.g. mid-stream provider auth failures the CLI
417
+ // surfaces as `subtype: "success"` synthetic-stop events, or the
418
+ // `errors[]` array from `error_*` subtypes). preferred over raw
419
+ // stdout/stderr in the exit-non-zero path so the GitHub Actions
420
+ // `##[error]` line shows the actionable message instead of an 8KB+
421
+ // NDJSON dump.
422
+ let lastResultError: string | null = null;
423
+ // set only for synthetic-stop `subtype: "success"` + `is_error: true`
424
+ // events, where `accumulatedTokens` from prior `assistant` events is
425
+ // stale and logging it would mislead operators into thinking billable
426
+ // tokens were spent on a successful turn. deliberately NOT set for
427
+ // `error_max_turns` / `error_during_execution` / `error_*` subtypes
428
+ // because those runs genuinely consumed tokens and operators need
429
+ // billing visibility for them.
430
+ let syntheticStopFailure = false;
431
+ let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
432
+ // Claude CLI reports a single end-of-run `total_cost_usd` on the result
433
+ // event. per-message events don't carry cost, so there's nothing to sum —
434
+ // we just capture the final value when it arrives.
435
+ let accumulatedCostUsd = 0;
436
+ let tokensLogged = false;
437
+
438
+ function buildUsage(): AgentUsage | undefined {
439
+ const totalInput =
440
+ accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
441
+ return totalInput > 0 || accumulatedTokens.output > 0
442
+ ? {
443
+ agent: "claude",
444
+ inputTokens: totalInput,
445
+ outputTokens: accumulatedTokens.output,
446
+ cacheReadTokens: accumulatedTokens.cacheRead || undefined,
447
+ cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
448
+ costUsd: accumulatedCostUsd > 0 ? accumulatedCostUsd : undefined,
449
+ }
450
+ : undefined;
451
+ }
452
+
453
+ const handlers = {
454
+ system: (event: ClaudeSystemEvent) => {
455
+ // claude-agent-sdk only emits system:init for the top-level query, so
456
+ // this binds the orchestrator label and never appears in subagent flow.
457
+ // we still route through eventLabel so a subagent system event (if the
458
+ // SDK ever adds one) wouldn't go silently misattributed.
459
+ const label = eventLabel(event);
460
+ log.debug(withLabel(label, `» ${params.label} system event`));
461
+ },
462
+ assistant: (event: ClaudeAssistantEvent) => {
463
+ const content = event.message?.content;
464
+ if (!content) return;
465
+
466
+ const label = eventLabel(event);
467
+ const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
468
+
469
+ for (const block of content) {
470
+ if (block.type === "text" && block.text?.trim()) {
471
+ const message = block.text.trim();
472
+ log.box(message, { title: boxTitle });
473
+ // only the orchestrator's text becomes the run's "output" — subagent
474
+ // report-back text would otherwise clobber the parent's final answer.
475
+ if (label === ORCHESTRATOR_LABEL) {
476
+ finalOutput = message;
477
+ }
478
+ } else if (block.type === "tool_use") {
479
+ const toolName = block.name || "unknown";
480
+ if (params.onToolUse) {
481
+ params.onToolUse({
482
+ toolName,
483
+ input: block.input,
484
+ });
485
+ }
486
+ timerFor(label).markToolCall();
487
+ const inputFormatted = formatJsonValue(block.input || {});
488
+ const toolCallLine =
489
+ inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
490
+ log.info(withLabel(label, toolCallLine));
491
+
492
+ // when the orchestrator dispatches a subagent, bind the Agent
493
+ // tool_use id to the dispatched label so future events carrying
494
+ // `parent_tool_use_id === block.id` resolve directly to the right
495
+ // lens. v2.1.63+ renamed the tool to "Agent"; older versions
496
+ // emitted "Task". match both for forward-compat.
497
+ if (
498
+ (toolName === "Task" || toolName === "Agent") &&
499
+ block.input &&
500
+ typeof block.input === "object"
501
+ ) {
502
+ const taskInput = block.input as {
503
+ description?: string;
504
+ subagent_type?: string;
505
+ prompt?: string;
506
+ };
507
+ const dispatchedLabel = labeler.recordTaskDispatch(taskInput, block.id ?? null);
508
+ log.info(
509
+ withLabel(
510
+ label,
511
+ `» dispatching subagent: ${dispatchedLabel}` +
512
+ (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : ""),
513
+ ),
514
+ );
515
+ }
516
+
517
+ // agent's explicit MCP report_progress takes priority over todo tracking
518
+ if (toolName.includes("report_progress") && params.todoTracker) {
519
+ log.debug("» report_progress detected, disabling todo tracking");
520
+ params.todoTracker.cancel();
521
+ }
522
+
523
+ // parse TodoWrite events for live progress tracking. only honor the
524
+ // orchestrator's todos — subagents emit their own todo lists which
525
+ // would otherwise clobber the visible progress comment.
526
+ if (
527
+ toolName === "TodoWrite" &&
528
+ params.todoTracker?.enabled &&
529
+ label === ORCHESTRATOR_LABEL
530
+ ) {
531
+ params.todoTracker.update(block.input);
532
+ }
533
+ }
534
+ }
535
+
536
+ // accumulate per-message usage if available. capture cache fields too
537
+ // so the fallback token table (used when no final `result` event fires)
538
+ // still reports the full breakdown instead of silently dropping cache.
539
+ const msgUsage = event.message?.usage;
540
+ if (msgUsage) {
541
+ accumulatedTokens.input += msgUsage.input_tokens || 0;
542
+ accumulatedTokens.output += msgUsage.output_tokens || 0;
543
+ accumulatedTokens.cacheRead += msgUsage.cache_read_input_tokens || 0;
544
+ accumulatedTokens.cacheWrite += msgUsage.cache_creation_input_tokens || 0;
545
+ }
546
+ },
547
+ user: (event: ClaudeUserEvent) => {
548
+ const content = event.message?.content;
549
+ if (!content) return;
550
+
551
+ const label = eventLabel(event);
552
+
553
+ for (const block of content) {
554
+ if (typeof block === "string") continue;
555
+ if (block.type === "tool_result") {
556
+ timerFor(label).markToolResult();
557
+
558
+ const outputContent =
559
+ typeof block.content === "string"
560
+ ? block.content
561
+ : Array.isArray(block.content)
562
+ ? (block.content as unknown[])
563
+ .map((entry: unknown) =>
564
+ typeof entry === "string"
565
+ ? entry
566
+ : typeof entry === "object" && entry !== null && "text" in entry
567
+ ? String((entry as { text: unknown }).text)
568
+ : JSON.stringify(entry),
569
+ )
570
+ .join("\n")
571
+ : String(block.content);
572
+
573
+ if (block.is_error) {
574
+ log.info(withLabel(label, `» tool error: ${outputContent}`));
575
+ } else {
576
+ log.debug(withLabel(label, `» tool output: ${outputContent}`));
577
+ }
578
+ }
579
+ }
580
+ },
581
+ result: (event: ClaudeResultEvent) => {
582
+ if (event.session_id) sessionId = event.session_id;
583
+ const subtype = event.subtype || "unknown";
584
+ const numTurns = event.num_turns || 0;
585
+
586
+ // claude CLI emits synthetic-stop result events with `subtype: "success"`
587
+ // but `is_error: true` when an upstream provider fails mid-stream (e.g.
588
+ // 401 from anthropic). short-circuit before the usage/token-table path
589
+ // so we don't log a usage table for a failed attempt and so downstream
590
+ // (`resultErrorSubtype` branch) surfaces the structured error. gated on
591
+ // `subtype === "success"` because the `error_*` subtypes also set
592
+ // `is_error: true` but carry their payload in `errors: string[]` and
593
+ // are handled by the dedicated branches below.
594
+ if (event.is_error === true && subtype === "success") {
595
+ const apiStatus = event.api_error_status;
596
+ lastResultError =
597
+ event.result?.trim() ||
598
+ `claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
599
+ resultErrorSubtype = subtype;
600
+ syntheticStopFailure = true;
601
+ log.info(
602
+ `» ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`,
603
+ );
604
+ return;
605
+ }
606
+
607
+ if (subtype === "success") {
608
+ // extract detailed usage from result event (most accurate source).
609
+ // note: `input` here is non-cached input tokens only, matching the
610
+ // semantics of OpenCode's step_finish.tokens.input — the logTokenTable
611
+ // helper sums Input + Cache Read + Cache Write + Output into the Total
612
+ // column so consumers get the real billable figure.
613
+ const usage = event.usage;
614
+ const inputTokens = usage?.input_tokens || 0;
615
+ const cacheRead = usage?.cache_read_input_tokens || 0;
616
+ const cacheWrite = usage?.cache_creation_input_tokens || 0;
617
+ const outputTokens = usage?.output_tokens || 0;
618
+ // guard against NaN/Infinity from malformed CLI output poisoning the total
619
+ const costUsd =
620
+ typeof event.total_cost_usd === "number" && Number.isFinite(event.total_cost_usd)
621
+ ? event.total_cost_usd
622
+ : 0;
623
+
624
+ accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite };
625
+ accumulatedCostUsd = costUsd;
626
+
627
+ log.info(`» ${params.label} result: subtype=${subtype}, turns=${numTurns}`);
628
+
629
+ if (!tokensLogged) {
630
+ logTokenTable({
631
+ input: inputTokens,
632
+ cacheRead,
633
+ cacheWrite,
634
+ output: outputTokens,
635
+ costUsd,
636
+ });
637
+ tokensLogged = true;
638
+ }
639
+ } else if (subtype === "error_max_turns") {
640
+ resultErrorSubtype = subtype;
641
+ lastResultError = event.errors?.join("\n").trim() || null;
642
+ log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
643
+ } else if (subtype === "error_during_execution") {
644
+ resultErrorSubtype = subtype;
645
+ lastResultError = event.errors?.join("\n").trim() || null;
646
+ log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
647
+ } else if (subtype.startsWith("error")) {
648
+ resultErrorSubtype = subtype;
649
+ lastResultError = event.errors?.join("\n").trim() || null;
650
+ log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
651
+ } else {
652
+ log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
653
+ }
654
+
655
+ if (event.result?.trim()) {
656
+ finalOutput = event.result.trim();
657
+ }
658
+ },
659
+ // additional Claude CLI event types — debug-logged only
660
+ stream_event: () => {},
661
+ tool_progress: () => {},
662
+ tool_use_summary: () => {},
663
+ auth_status: () => {},
664
+ };
665
+
666
+ const recentStderr: string[] = [];
667
+ // ring buffer of recent non-JSON stdout lines. Claude CLI prints
668
+ // human-readable TTY chrome (status bubbles, quota notices, etc.)
669
+ // alongside the NDJSON event stream. when the CLI exits non-zero without
670
+ // emitting a structured error event, these lines are the only actionable
671
+ // signal — preferring them over the NDJSON tail keeps progress comments
672
+ // readable. issue #643.
673
+ const recentNonJsonStdout: string[] = [];
674
+
675
+ let lastProviderError: string | null = null;
676
+
677
+ // capped accumulator — see opencode.ts for rationale (issue #680).
678
+ const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
679
+ let stdoutBuffer = "";
680
+
681
+ try {
682
+ const result = await spawn({
683
+ cmd: params.cmd,
684
+ args: params.args,
685
+ cwd: params.cwd,
686
+ env: params.env,
687
+ // flat agent idle budget — long synchronous MCP tool calls (issue #760)
688
+ // sit well under it, so no per-toolcall suspend bracketing is needed.
689
+ activityTimeout: AGENT_ACTIVITY_TIMEOUT_MS,
690
+ onActivityTimeout: params.onActivityTimeout,
691
+ stdio: ["ignore", "pipe", "pipe"],
692
+ // run claude in its own process group so SIGKILL on activity timeout /
693
+ // outer cancellation reaches any subprocesses it spawns (rg, file
694
+ // watchers, mcp transports, etc). claude (2.1.113+) is now a native
695
+ // binary like opencode-ai/bin/opencode, so detached + killGroup is
696
+ // required to avoid orphaning the binary and its children.
697
+ killGroup: true,
698
+ // claude already drains every chunk via onStdout (NDJSON parsing) and
699
+ // onStderr (recentStderr ring buffer). retaining a second copy in the
700
+ // spawn wrapper would grow unbounded for long sessions and previously
701
+ // crashed the wrapper with RangeError. see issue #680.
702
+ retain: "none",
703
+ onStdout: async (chunk) => {
704
+ const text = chunk.toString();
705
+ output.append(text);
706
+ markActivity();
707
+
708
+ stdoutBuffer += text;
709
+ const lines = stdoutBuffer.split("\n");
710
+ stdoutBuffer = lines.pop() || "";
711
+
712
+ for (const line of lines) {
713
+ const trimmed = line.trim();
714
+ if (!trimmed) continue;
715
+
716
+ let event: ClaudeEvent;
717
+ try {
718
+ event = JSON.parse(trimmed) as ClaudeEvent;
719
+ } catch {
720
+ log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
721
+ recentNonJsonStdout.push(trimmed);
722
+ if (recentNonJsonStdout.length > MAX_STDERR_LINES) recentNonJsonStdout.shift();
723
+ continue;
724
+ }
725
+
726
+ eventCount++;
727
+ log.debug(JSON.stringify(event, null, 2));
728
+
729
+ const timeSinceLastActivity = getIdleMs();
730
+ if (timeSinceLastActivity > 10000) {
731
+ log.info(
732
+ `» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)`,
733
+ );
734
+ }
735
+ markActivity();
736
+
737
+ const handler = handlers[event.type as keyof typeof handlers];
738
+ if (!handler) {
739
+ log.debug(`» ${params.label} event (unhandled): type=${event.type}`);
740
+ continue;
741
+ }
742
+ try {
743
+ (handler as (e: ClaudeEvent) => void)(event);
744
+ } catch (err) {
745
+ log.info(
746
+ `» ${params.label} handler for type=${event.type} threw: ${err instanceof Error ? err.message : String(err)}`,
747
+ );
748
+ }
749
+ }
750
+ },
751
+ onStderr: (chunk) => {
752
+ const trimmed = chunk.trim();
753
+ if (!trimmed) return;
754
+
755
+ recentStderr.push(trimmed);
756
+ if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
757
+
758
+ const match = findProviderErrorMatch(trimmed);
759
+ if (match) {
760
+ lastProviderError = match.label;
761
+ log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
762
+ } else {
763
+ log.debug(trimmed);
764
+ }
765
+ },
766
+ });
767
+
768
+ if (result.exitCode === 0) {
769
+ await params.todoTracker?.flush();
770
+ } else {
771
+ params.todoTracker?.cancel();
772
+ }
773
+
774
+ const duration = performance.now() - startTime;
775
+ log.info(
776
+ `» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`,
777
+ );
778
+
779
+ if (eventCount === 0) {
780
+ const stderrContext = recentStderr.join("\n");
781
+ const diagnosis = lastProviderError
782
+ ? `provider error: ${lastProviderError}`
783
+ : "unknown cause (no stdout events received)";
784
+ log.info(`» ${params.label} produced 0 events (${diagnosis})`);
785
+ if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
786
+ }
787
+
788
+ // skip the fallback token table only for the synthetic-stop
789
+ // `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from
790
+ // prior `assistant` events is stale there and logging it would mislead
791
+ // operators into thinking billable tokens were spent on a successful turn.
792
+ // `error_max_turns` / `error_during_execution` / `error_*` subtypes
793
+ // represent runs that genuinely consumed tokens, so they still get the
794
+ // table for billing visibility.
795
+ if (
796
+ !tokensLogged &&
797
+ !syntheticStopFailure &&
798
+ (accumulatedTokens.input > 0 ||
799
+ accumulatedTokens.output > 0 ||
800
+ accumulatedTokens.cacheRead > 0 ||
801
+ accumulatedTokens.cacheWrite > 0)
802
+ ) {
803
+ logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
804
+ tokensLogged = true;
805
+ }
806
+
807
+ const usage = buildUsage();
808
+
809
+ if (result.exitCode !== 0) {
810
+ const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
811
+ // prefer the structured `lastResultError` (parsed from a result event
812
+ // with `is_error: true`) over raw stdout. raw stdout is the full NDJSON
813
+ // event stream — dumping it into a GitHub Actions `##[error]` line both
814
+ // hides the actionable provider message and pollutes the run log. cap
815
+ // the stdout fallback to the last 2KB so it stays readable when neither
816
+ // a structured error nor stderr is available.
817
+ //
818
+ // result.stdout / result.stderr are empty because we pass retain:"none"
819
+ // to spawn (see issue #680); the agent layer keeps its own bounded
820
+ // mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer).
821
+ const stdoutSnapshot = output.toString();
822
+ const stderrSnapshot = recentStderr.join("\n");
823
+ const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
824
+ // prefer non-JSON stdout (human-readable TTY chrome the CLI prints,
825
+ // including status bubbles and quota notices) over the raw NDJSON
826
+ // tail. when the CLI exits 1 without emitting `is_error` (issue #643),
827
+ // the NDJSON fallback would otherwise dump 2KB of `system/init` events
828
+ // into the progress comment with no mention of the actual cause.
829
+ const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n");
830
+ const errorMessage =
831
+ lastResultError ||
832
+ stderrSnapshot ||
833
+ nonJsonStdoutSnapshot ||
834
+ truncatedStdout ||
835
+ `unknown error - no output from Claude CLI${errorContext}`;
836
+ log.error(
837
+ `${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`,
838
+ );
839
+ log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
840
+ log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
841
+ return {
842
+ success: false,
843
+ output: finalOutput || stdoutSnapshot,
844
+ error: errorMessage,
845
+ usage,
846
+ sessionId,
847
+ };
848
+ }
849
+
850
+ if (eventCount === 0 && lastProviderError) {
851
+ return {
852
+ success: false,
853
+ output: finalOutput || output.toString(),
854
+ error: `provider error: ${lastProviderError}`,
855
+ usage,
856
+ sessionId,
857
+ };
858
+ }
859
+
860
+ if (resultErrorSubtype) {
861
+ return {
862
+ success: false,
863
+ output: finalOutput || output.toString(),
864
+ error: lastResultError || `result subtype: ${resultErrorSubtype}`,
865
+ usage,
866
+ sessionId,
867
+ };
868
+ }
869
+
870
+ return { success: true, output: finalOutput || output.toString(), usage, sessionId };
871
+ } catch (error) {
872
+ params.todoTracker?.cancel();
873
+ const duration = performance.now() - startTime;
874
+ const errorMessage = error instanceof Error ? error.message : String(error);
875
+ const isActivityTimeout =
876
+ error instanceof SpawnTimeoutError && error.code === SPAWN_ACTIVITY_TIMEOUT_CODE;
877
+
878
+ const stderrContext = recentStderr.slice(-10).join("\n");
879
+ const diagnosis = lastProviderError
880
+ ? `likely cause: ${lastProviderError}`
881
+ : eventCount === 0
882
+ ? "Claude produced 0 stdout events - check if the API is reachable"
883
+ : `${eventCount} events were processed before the hang`;
884
+
885
+ log.info(
886
+ `» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`,
887
+ );
888
+ log.info(`» diagnosis: ${diagnosis}`);
889
+ if (stderrContext)
890
+ log.info(
891
+ `» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`,
892
+ );
893
+
894
+ return {
895
+ success: false,
896
+ output: finalOutput || output.toString(),
897
+ error: `${errorMessage} [${diagnosis}]`,
898
+ usage: buildUsage(),
899
+ sessionId,
900
+ };
901
+ }
902
+ }
903
+
904
+ // ── managed settings ────────────────────────────────────────────────────────────
905
+
906
+ const MANAGED_SETTINGS_DIR = "/etc/claude-code";
907
+ const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
908
+
909
+ // managed-settings.json has absolute highest precedence in Claude Code's config hierarchy.
910
+ // it cannot be overridden by user, project, or local settings — safe against malicious PRs.
911
+ //
912
+ // permissions.deny blocks native tools (Read, Grep, Edit, Glob) from accessing /proc and /sys,
913
+ // the git surfaces (blanket Edit(.git/**) write deny + narrow .git/config read deny — see
914
+ // nativeFsDenies.ts), and any path passed in via ctx.secretDenyPaths (codex auth dir, vertex
915
+ // creds dir, etc.).
916
+ // sandbox.filesystem.denyRead blocks the Bash tool sandbox from reading those paths.
917
+ // allowManagedPermissionRulesOnly prevents malicious PRs from adding allow rules that override
918
+ // our deny rules — safe in CI because --dangerously-skip-permissions makes allow/ask irrelevant.
919
+ // allowManagedHooksOnly prevents malicious project hooks from bypassing deny rules.
920
+ // Per Claude Code permissions docs, Read(...) deny ALSO blocks file-reading Bash commands
921
+ // (cat, head, tail, sed) and survives bypassPermissions mode. See wiki/security.md and
922
+ // wiki/codex-auth.md.
923
+
924
+ /**
925
+ * env var carrying the gate-server URL to the Claude subprocess. the Stop
926
+ * hook curls it on every stop; an absent value disables the hook (e.g.
927
+ * non-CI local dev paths that don't install managed settings either).
928
+ */
929
+ const STOP_HOOK_GATE_URL_ENV = "TERRAMEND_GATE_URL";
930
+ // `_TOKEN` suffix is intentional: filterEnv() strips it from the agent's shell
931
+ // sandbox, so only the Stop hook (a child of this process) can authenticate to
932
+ // the gate server. See gateServer.ts.
933
+ const STOP_HOOK_GATE_TOKEN_ENV = "TERRAMEND_GATE_TOKEN";
934
+
935
+ /**
936
+ * managed Stop hook. swaps the old `--resume <sessionId>` follow-up
937
+ * subprocesses (reflection + every gate retry — cost audit on PR #792
938
+ * showed reflection alone burned ~$0.85 / 111K cache_write per Opus run,
939
+ * almost all of it wasted re-running `getAttachmentMessages` in the fresh
940
+ * process) for a `{decision: "block", reason: ...}` injection inside the
941
+ * live `queryLoop`. existing session context is already in the prompt
942
+ * cache so only the new reason text is fresh cache_write.
943
+ *
944
+ * the script is intentionally minimal — all decision logic lives in the
945
+ * sidecar gate server (`gateServer.ts`), which reads live `ctx.toolState`
946
+ * mutations from the same process the MCP server runs in. budget +
947
+ * one-shot tracking lives there too, so re-fires across multiple stops in
948
+ * one session are safe. claude-code's 8-consecutive-block override is the
949
+ * last-line backstop.
950
+ */
951
+ export function buildStopHookScript(): string {
952
+ return [
953
+ "#!/usr/bin/env bash",
954
+ "set -euo pipefail",
955
+ `url="\${${STOP_HOOK_GATE_URL_ENV}:-}"`,
956
+ `tok="\${${STOP_HOOK_GATE_TOKEN_ENV}:-}"`,
957
+ 'if [ -z "$url" ]; then exit 0; fi',
958
+ "cat >/dev/null",
959
+ 'response=$(curl -fsS --max-time 30 -H "Authorization: Bearer $tok" "$url" 2>/dev/null || printf \'{"block":false}\')',
960
+ 'block=$(printf "%s" "$response" | jq -r ".block // false")',
961
+ 'if [ "$block" != "true" ]; then exit 0; fi',
962
+ 'reason=$(printf "%s" "$response" | jq -r ".reason // \\"\\"")',
963
+ 'if [ -z "$reason" ]; then exit 0; fi',
964
+ 'jq -n --arg reason "$reason" \'{decision: "block", reason: $reason}\'',
965
+ "",
966
+ ].join("\n");
967
+ }
968
+
969
+ export interface ManagedSettingsParams {
970
+ ctx: AgentRunContext;
971
+ stopHookPath: string | null;
972
+ pretoolGateScriptPath: string;
973
+ }
974
+
975
+ export function buildManagedSettings(params: ManagedSettingsParams): Record<string, unknown> {
976
+ const secretDenyPaths = params.ctx.secretDenyPaths ?? [];
977
+ const toolDeny = secretDenyPaths.flatMap((path) => [
978
+ `Read(${path}/**)`,
979
+ `Read(/${path}/**)`,
980
+ `Grep(${path}/**)`,
981
+ `Grep(/${path}/**)`,
982
+ `Edit(${path}/**)`,
983
+ `Edit(/${path}/**)`,
984
+ `Glob(${path}/**)`,
985
+ `Glob(/${path}/**)`,
986
+ ]);
987
+ // single builder for both the PreToolUse gate hook and the native exec-tool
988
+ // deny — both fields are consumed here (and identically in the flag-settings
989
+ // path via writePretoolGateAssets), keeping CLAUDE_EXEC_TOOL_DENY_RULES the
990
+ // single source.
991
+ const gate = buildClaudePretoolGateSettings(
992
+ params.pretoolGateScriptPath,
993
+ CLAUDE_EXEC_TOOL_DENY_RULES,
994
+ );
995
+ const base: Record<string, unknown> = {
996
+ allowManagedPermissionRulesOnly: true,
997
+ allowManagedHooksOnly: true,
998
+ permissions: {
999
+ deny: [
1000
+ // native exec tools — the authoritative, bypass-immune deny.
1001
+ // `--disallowedTools` (a cliArg-source deny) leaked under
1002
+ // `--dangerously-skip-permissions`; policySettings denies survive
1003
+ // bypassPermissions mode. covers top-level + Agent(...) subagent use.
1004
+ ...gate.permissions.deny,
1005
+ "Read(//proc/**)",
1006
+ "Read(//sys/**)",
1007
+ "Grep(//proc/**)",
1008
+ "Grep(//sys/**)",
1009
+ "Edit(//proc/**)",
1010
+ "Edit(//sys/**)",
1011
+ "Glob(//proc/**)",
1012
+ "Glob(//sys/**)",
1013
+ // git surfaces — blanket Edit(.git/**) write deny (nothing legit
1014
+ // writes .git via native tools; real commits go through MCP git tools
1015
+ // outside this gate) + narrow Read/Grep/Glob(.git/config) read deny.
1016
+ // mirrors opencode's edit-blanket / read-narrow split. canonical:
1017
+ // action/agents/nativeFsDenies.ts.
1018
+ ...GIT_NATIVE_WRITE_DENY_CLAUDE,
1019
+ ...GIT_NATIVE_READ_DENY_CLAUDE,
1020
+ ...toolDeny,
1021
+ ],
1022
+ },
1023
+ sandbox: {
1024
+ filesystem: {
1025
+ denyRead: ["/proc", "/sys", ...secretDenyPaths],
1026
+ },
1027
+ },
1028
+ };
1029
+ // PreToolUse gate replicated into managed settings so it survives the
1030
+ // `allowManagedHooksOnly: true` policy gate (see
1031
+ // src/utils/hooks/hooksConfigSnapshot.ts in claude-code source). the Stop
1032
+ // hook (gate-server retries) is layered into the same `hooks` object when
1033
+ // present so both fire under managed settings.
1034
+ const hooks: Record<string, unknown> = {
1035
+ ...gate.hooks,
1036
+ };
1037
+ if (params.stopHookPath) {
1038
+ hooks.Stop = [
1039
+ {
1040
+ hooks: [{ type: "command", command: params.stopHookPath }],
1041
+ },
1042
+ ];
1043
+ }
1044
+ base.hooks = hooks;
1045
+ return base;
1046
+ }
1047
+
1048
+ function installManagedSettings(params: ManagedSettingsParams): void {
1049
+ if (process.env.CI !== "true") return;
1050
+
1051
+ const content = JSON.stringify(buildManagedSettings(params), null, 2);
1052
+ try {
1053
+ execFileSync("sudo", ["mkdir", "-p", MANAGED_SETTINGS_DIR]);
1054
+ execFileSync("sudo", ["tee", MANAGED_SETTINGS_PATH], {
1055
+ input: content,
1056
+ stdio: ["pipe", "ignore", "pipe"],
1057
+ });
1058
+ log.debug(`» wrote managed settings to ${MANAGED_SETTINGS_PATH}`);
1059
+ } catch (err) {
1060
+ log.warning(`» failed to install managed settings: ${err}`);
1061
+ }
1062
+ }
1063
+
1064
+ // ── agent ───────────────────────────────────────────────────────────────────────
1065
+
1066
+ export const claude = agent({
1067
+ name: "claude",
1068
+ install: installClaudeCli,
1069
+ run: async (ctx) => {
1070
+ const cliPath = await installClaudeCli();
1071
+
1072
+ const specifier = ctx.resolvedModel;
1073
+ // claude-code on Bedrock takes the bare AWS model ID — no provider prefix
1074
+ // to strip, since the ID is already in `provider.model` form (e.g.
1075
+ // `eu.anthropic.claude-opus-4-7`). detect via the env-var sentinel: if
1076
+ // BEDROCK_MODEL_ID is set and matches the resolved specifier, this is a
1077
+ // bedrock route. see `wiki/model-resolution.md` for the routing pattern.
1078
+ const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
1079
+ const isBedrockRoute =
1080
+ specifier !== undefined &&
1081
+ bedrockModelId !== undefined &&
1082
+ bedrockModelId === specifier &&
1083
+ isBedrockAnthropicId(specifier);
1084
+ const vertexModelId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
1085
+ const isVertexRoute =
1086
+ specifier !== undefined &&
1087
+ vertexModelId !== undefined &&
1088
+ vertexModelId === specifier &&
1089
+ isVertexAnthropicId(specifier);
1090
+ const model = !specifier
1091
+ ? undefined
1092
+ : isBedrockRoute
1093
+ ? specifier
1094
+ : isVertexRoute
1095
+ ? undefined
1096
+ : stripProviderPrefix(specifier);
1097
+
1098
+ const homeEnv = {
1099
+ HOME: ctx.tmpdir,
1100
+ XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
1101
+ };
1102
+
1103
+ mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true });
1104
+
1105
+ installBundledSkills({ home: homeEnv.HOME });
1106
+
1107
+ const mcpConfigPath = writeMcpConfig(ctx);
1108
+ const effort = resolveEffort(model);
1109
+
1110
+ // PreToolUse gate that hard-blocks state-mutating MCP tool calls from
1111
+ // subagents (the `agent_id` field is non-empty in the hook input only
1112
+ // for subagent-originated calls — verified against
1113
+ // yasasbanukaofficial/claude-code src/utils/hooks.ts createBaseHookInput).
1114
+ // Wired via two surfaces so it fires in both CI and local (see
1115
+ // writePretoolGateAssets / buildManagedSettings comments).
1116
+ const pretoolGate = writePretoolGateAssets(ctx);
1117
+
1118
+ // reflection + every gate retry (dirty tree, unsubmitted review, summary
1119
+ // stale) move from post-exit `--resume <sessionId>` subprocesses to a
1120
+ // managed Stop hook that curls a sidecar gate server. see
1121
+ // `buildStopHookScript` for the cost rationale (PR #792 audit) and
1122
+ // `gateServer.ts` for the decision policy.
1123
+ const stopHookPath = join(ctx.tmpdir, "terramend-stop-hook.sh");
1124
+ writeFileSync(stopHookPath, buildStopHookScript(), { mode: 0o755 });
1125
+
1126
+ installManagedSettings({ ctx, stopHookPath, pretoolGateScriptPath: pretoolGate.scriptPath });
1127
+
1128
+ // base args shared between initial run and continue runs
1129
+ const baseArgs = [
1130
+ "--output-format",
1131
+ "stream-json",
1132
+ "--dangerously-skip-permissions",
1133
+ "--mcp-config",
1134
+ mcpConfigPath,
1135
+ "--settings",
1136
+ pretoolGate.settingsPath,
1137
+ "--verbose",
1138
+ "--effort",
1139
+ effort,
1140
+ "--disallowedTools",
1141
+ CLAUDE_DISALLOWED_TOOLS,
1142
+ "--agents",
1143
+ buildAgentsJson(),
1144
+ ];
1145
+
1146
+ if (model) {
1147
+ baseArgs.push("--model", model);
1148
+ }
1149
+
1150
+ // agent process gets full env — needs LLM API keys, PATH, locale, etc.
1151
+ // security is enforced via managed-settings.json, --disallowedTools (native exec tools), and MCP tool filtering.
1152
+ //
1153
+ // bedrock route: claude-code reads `CLAUDE_CODE_USE_BEDROCK=1` to switch
1154
+ // its provider implementation from the direct Anthropic API to Bedrock.
1155
+ // AWS_BEARER_TOKEN_BEDROCK / AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY +
1156
+ // AWS_REGION are already in process.env from the workflow's `env:` block.
1157
+ // see https://docs.claude.com/en/docs/claude-code/amazon-bedrock.
1158
+ //
1159
+ // we only force CLAUDE_CODE_USE_BEDROCK=1 when this is a Terramend-routed
1160
+ // bedrock run; if the user has set the env var manually for some other
1161
+ // reason (e.g. always-Bedrock org policy), `...process.env` already
1162
+ // carries it through and we don't disturb it.
1163
+ const repoDir = process.cwd();
1164
+
1165
+ // PWD must match the spawn cwd (see opencode.ts for the analogous fix).
1166
+ // claude-code 2.1.x reads `process.env.PWD` and registers it as a "session"
1167
+ // additional-working-directory when it differs from `process.cwd()` (per
1168
+ // the bundled cli.js — `let H=process.env.PWD; if(H && H !== Y7() && ...)
1169
+ // j.set(H, {path: H, source: "session"})`). Inheriting harness PWD via
1170
+ // `...process.env` ends up adding the wrong dir to the agent's allowed
1171
+ // working set under `pnpm runtest` / `pnpm dev:run`, which silently confuses
1172
+ // path-relative tools.
1173
+ const env: Record<string, string | undefined> = {
1174
+ ...process.env,
1175
+ ...homeEnv,
1176
+ PWD: repoDir,
1177
+ };
1178
+ if (isBedrockRoute) {
1179
+ env.CLAUDE_CODE_USE_BEDROCK = "1";
1180
+ }
1181
+ if (isVertexRoute) {
1182
+ applyClaudeVertexEnv(env);
1183
+ env.ANTHROPIC_MODEL = specifier;
1184
+ }
1185
+
1186
+ // claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth
1187
+ // token when both are set, so we strip the API key to fall through to the
1188
+ // Max-subscription path. bedrock route uses AWS creds and is excluded.
1189
+ // the strip is gated on a 1-token preflight: an exhausted (session/weekly
1190
+ // limit) or revoked subscription would otherwise kill the run at its first
1191
+ // model call with a working API key sitting unused in env.
1192
+ if (env.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env.ANTHROPIC_API_KEY) {
1193
+ const preflight = await preflightClaudeSubscription({
1194
+ token: env.CLAUDE_CODE_OAUTH_TOKEN,
1195
+ model,
1196
+ });
1197
+ if (preflight.usable) {
1198
+ log.debug(
1199
+ "» CLAUDE_CODE_OAUTH_TOKEN present — stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used",
1200
+ );
1201
+ delete env.ANTHROPIC_API_KEY;
1202
+ } else {
1203
+ log.info(
1204
+ `» Claude subscription unusable (${preflight.reason}) — falling back to ANTHROPIC_API_KEY`,
1205
+ );
1206
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
1207
+ }
1208
+ }
1209
+
1210
+ log.info(`» effort: ${effort}`);
1211
+ log.debug(`» starting Terramend (Claude Code): ${cliPath} ${baseArgs.join(" ")}`);
1212
+ log.debug(`» working directory: ${repoDir}`);
1213
+
1214
+ // gate server lives only as long as the claude subprocess does. the
1215
+ // Stop hook curls `gateServer.url` and turns the response into its
1216
+ // `{decision: "block", reason}` payload (or exits 0 to allow stop).
1217
+ await using gateServer = await startGateServer(ctx);
1218
+
1219
+ const result = await runClaude({
1220
+ label: "Terramend",
1221
+ cmd: cliPath,
1222
+ cwd: repoDir,
1223
+ env: {
1224
+ ...env,
1225
+ [STOP_HOOK_GATE_URL_ENV]: gateServer.url,
1226
+ [STOP_HOOK_GATE_TOKEN_ENV]: gateServer.token,
1227
+ // the MCP client (this Claude process) expands ${TERRAMEND_MCP_TOKEN}
1228
+ // in mcp.json headers from here; filterEnv() strips it from the MCP
1229
+ // shell sandbox so a sandboxed command can't read it.
1230
+ [MCP_SERVER_TOKEN_ENV]: ctx.mcpServerToken,
1231
+ },
1232
+ todoTracker: ctx.todoTracker,
1233
+ onActivityTimeout: ctx.onActivityTimeout,
1234
+ onToolUse: ctx.onToolUse,
1235
+ args: [...baseArgs, "-p", ctx.instructions.full],
1236
+ });
1237
+
1238
+ // every follow-up turn (reflection + gate retries) has already happened
1239
+ // inside this single subprocess via the Stop hook, so usage aggregation
1240
+ // and resume orchestration are no-ops. all that remains is the terminal
1241
+ // hard-fail render: when the budget exhausted with `stopHook` /
1242
+ // `unsubmittedReview` still failing, flip `success` to false with the
1243
+ // same error shape `runPostRunRetryLoop` produced pre-migration.
1244
+ return finalizeAgentResult({ ctx, result });
1245
+ },
1246
+ });