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.
- package/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION } from "#app/agents/claudePretoolGate";
|
|
4
|
+
|
|
5
|
+
// Tripwire for the subagent gate's load-bearing assumption: claude-code
|
|
6
|
+
// populates `agent_id` in the PreToolUse hook payload for subagent tool calls
|
|
7
|
+
// (the gate fails OPEN for subagents otherwise). claude-code ships as a native
|
|
8
|
+
// binary, so we can't assert the behavior statically — instead we pin the
|
|
9
|
+
// verified version and fail CI on any bump, forcing a human to re-verify
|
|
10
|
+
// `createBaseHookInput` before updating the pin + the constant together.
|
|
11
|
+
describe("subagent gate ↔ claude-code agent_id contract", () => {
|
|
12
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as {
|
|
13
|
+
devDependencies?: Record<string, string>;
|
|
14
|
+
dependencies?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
it("pinned @anthropic-ai/claude-code matches the verified version", () => {
|
|
18
|
+
const pinned =
|
|
19
|
+
pkg.devDependencies?.["@anthropic-ai/claude-code"] ??
|
|
20
|
+
pkg.dependencies?.["@anthropic-ai/claude-code"];
|
|
21
|
+
expect(
|
|
22
|
+
pinned,
|
|
23
|
+
"claude-code was bumped: re-verify that subagent tool calls still populate " +
|
|
24
|
+
"`agent_id` in the PreToolUse hook (createBaseHookInput) BEFORE updating " +
|
|
25
|
+
"CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION — the subagent gate fails OPEN otherwise.",
|
|
26
|
+
).toBe(CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code `PreToolUse` hook source — written into `ctx.tmpdir` at runtime
|
|
3
|
+
* and registered via a tmpdir-scoped `settings.json` referenced by
|
|
4
|
+
* `--settings <path>` (see action/agents/claude.ts).
|
|
5
|
+
*
|
|
6
|
+
* Closes the subagent → state-mutating MCP tool path that motivated the
|
|
7
|
+
* 2026-05-18 zed-industries/cloud incident (`reviewfrog` lens called
|
|
8
|
+
* `checkout_pr` mid-review and the orchestrator's next push clobbered an
|
|
9
|
+
* unrelated branch). Pairs with the `tool.execute.before` hook in
|
|
10
|
+
* action/agents/opencodePlugin.ts; both runtimes share the deny list at
|
|
11
|
+
* action/agents/subagentToolGates.ts.
|
|
12
|
+
*
|
|
13
|
+
* PreToolUse hook contract (verified against yasasbanukaofficial/claude-code
|
|
14
|
+
* `src/utils/hooks/hooksConfigManager.ts` and `src/utils/hooks.ts`):
|
|
15
|
+
* - stdin: JSON with `hook_event_name: "PreToolUse"`, `tool_name`,
|
|
16
|
+
* `tool_input`, `tool_use_id`, `session_id`, `cwd`, `transcript_path`,
|
|
17
|
+
* and crucially `agent_id` / `agent_type` populated when the call
|
|
18
|
+
* originates from a subagent (set by the SDK when a Task/Agent
|
|
19
|
+
* dispatches a tool — see `createBaseHookInput` in claude-code source).
|
|
20
|
+
* - exit 0 → allow, no output shown
|
|
21
|
+
* - exit 2 → block tool call AND show stderr to model (this is the path
|
|
22
|
+
* we want for the deny case — the subagent gets a clear refusal it can
|
|
23
|
+
* reason about and pick a different action)
|
|
24
|
+
* - other → show stderr to user only, continue with tool call
|
|
25
|
+
*
|
|
26
|
+
* The hook itself is intentionally tiny: stdin → JSON → check `agent_id`
|
|
27
|
+
* presence + `tool_name` against the deny list → exit 0 or 2. No deps.
|
|
28
|
+
*
|
|
29
|
+
* Why the script source is a string template, not a separate `.ts` file
|
|
30
|
+
* shipped with the action: the action runs as a published npm package; at
|
|
31
|
+
* install time we don't have the source on disk in a stable place. Embedding
|
|
32
|
+
* the source into `dist/main.mjs` and writing it out per-run keeps the path
|
|
33
|
+
* inside `ctx.tmpdir` (where `--settings` can find it) and survives bundle
|
|
34
|
+
* minification.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { SUBAGENT_DENIED_TOOLS } from "#app/agents/subagentToolGates";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The pinned `@anthropic-ai/claude-code` version against which the subagent
|
|
41
|
+
* gate's `agent_id` discriminator was last verified (see the contract notes in
|
|
42
|
+
* the gate source below). The gate fails OPEN for subagents if claude-code ever
|
|
43
|
+
* stops populating `agent_id` in the PreToolUse hook payload, so a version bump
|
|
44
|
+
* must be paired with a re-verification of `createBaseHookInput`.
|
|
45
|
+
*
|
|
46
|
+
* `claudePretoolGate.test.ts` asserts this equals the version pinned in
|
|
47
|
+
* `package.json` — that test fails on any bump, forcing the re-verification
|
|
48
|
+
* before the pin and this constant are updated together.
|
|
49
|
+
*
|
|
50
|
+
* 2.1.170 verified 2026-06-10 against the schema embedded in the shipped
|
|
51
|
+
* binary: the base hook input declares `agent_id` as optional with the
|
|
52
|
+
* describe-text "Present only when the hook fires from within a subagent
|
|
53
|
+
* (e.g., a tool called by an AgentTool worker). Absent for the main thread,
|
|
54
|
+
* even in --agent sessions." — exactly the discriminator the gate relies on.
|
|
55
|
+
*/
|
|
56
|
+
export const CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION = "2.1.170" as const;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Source written to `<ctx.tmpdir>/terramend-pretool-gate.mjs`. Plain ESM,
|
|
60
|
+
* no TypeScript, no dependencies — node executes it directly via the
|
|
61
|
+
* `#!/usr/bin/env node` shebang and the executable bit set by the harness.
|
|
62
|
+
*/
|
|
63
|
+
export const CLAUDE_PRETOOL_GATE_FILENAME = "terramend-pretool-gate.mjs" as const;
|
|
64
|
+
|
|
65
|
+
export const CLAUDE_PRETOOL_GATE_SOURCE = `#!/usr/bin/env node
|
|
66
|
+
// AUTOGENERATED by Terramend. PreToolUse hook that hard-blocks state-mutating
|
|
67
|
+
// MCP tool calls from subagents. See action/agents/claudePretoolGate.ts.
|
|
68
|
+
|
|
69
|
+
const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(SUBAGENT_DENIED_TOOLS)});
|
|
70
|
+
|
|
71
|
+
function stripMcpPrefix(toolName) {
|
|
72
|
+
if (toolName.startsWith("mcp__terramend__")) return toolName.slice("mcp__terramend__".length);
|
|
73
|
+
return toolName;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let stdin = "";
|
|
77
|
+
process.stdin.setEncoding("utf8");
|
|
78
|
+
process.stdin.on("data", (chunk) => {
|
|
79
|
+
stdin += chunk;
|
|
80
|
+
});
|
|
81
|
+
process.stdin.on("end", () => {
|
|
82
|
+
let payload;
|
|
83
|
+
try {
|
|
84
|
+
payload = JSON.parse(stdin);
|
|
85
|
+
} catch {
|
|
86
|
+
// malformed input — exit non-blocking so the run continues. user-only
|
|
87
|
+
// stderr per the hook contract.
|
|
88
|
+
process.stderr.write("terramend-pretool-gate: malformed stdin JSON\\n");
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const toolName = typeof payload?.tool_name === "string" ? payload.tool_name : "";
|
|
92
|
+
// claude-code populates agent_id whenever a tool call originates inside
|
|
93
|
+
// a Task/Agent subagent dispatch (createBaseHookInput in claude-code
|
|
94
|
+
// source). on the orchestrator's main thread agent_id is undefined.
|
|
95
|
+
// agent_type can be set on the orchestrator itself via --agent, so it's
|
|
96
|
+
// not a reliable subagent discriminator on its own; agent_id is.
|
|
97
|
+
// contract verified against @anthropic-ai/claude-code 2.1.170 (pinned in
|
|
98
|
+
// package.json; see CLAUDE_CODE_AGENT_ID_VERIFIED_VERSION + the version
|
|
99
|
+
// tripwire in claudePretoolGate.test.ts); re-verify createBaseHookInput if
|
|
100
|
+
// that bumps — if agent_id ever stops being populated the gate fails OPEN.
|
|
101
|
+
const agentId = typeof payload?.agent_id === "string" ? payload.agent_id : "";
|
|
102
|
+
if (!agentId) process.exit(0);
|
|
103
|
+
const bare = stripMcpPrefix(toolName);
|
|
104
|
+
if (!SUBAGENT_DENIED_TOOLS.has(bare)) process.exit(0);
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
"subagent attempted to call denied tool '" + bare + "'. " +
|
|
107
|
+
"subagents share the orchestrator's in-process working tree and toolState; " +
|
|
108
|
+
"state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
|
|
109
|
+
"report_progress, etc.) are reserved for the orchestrator. " +
|
|
110
|
+
"report findings back to the orchestrator and let it perform the mutation.\\n"
|
|
111
|
+
);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
});
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Settings JSON shape registered via `claude --settings <path>`. The
|
|
118
|
+
* matcher `^mcp__terramend__` is treated as a regex by claude-code's
|
|
119
|
+
* `matchesPattern` helper (anything outside `[a-zA-Z0-9_|]` triggers the
|
|
120
|
+
* regex branch — verified in src/utils/hooks.ts), so this anchors at the
|
|
121
|
+
* start of the tool name and fires for every Terramend MCP tool. We narrow
|
|
122
|
+
* inside the script itself rather than declaring per-tool matchers because
|
|
123
|
+
* the deny list is the source of truth.
|
|
124
|
+
*
|
|
125
|
+
* The hook process inherits the parent's PATH, so `node` resolves to the
|
|
126
|
+
* runner's node binary; the `--settings` flag accepts either a path or a
|
|
127
|
+
* literal JSON string per claude-code source `src/main.tsx` (`Path to a
|
|
128
|
+
* settings JSON file or a JSON string`), but we use a path so the script
|
|
129
|
+
* and its config sit side-by-side under `ctx.tmpdir`.
|
|
130
|
+
*
|
|
131
|
+
* `execToolDenyRules` are the native exec tools (Bash/Monitor/REPL/Workflow +
|
|
132
|
+
* their `Agent(...)` forms) to deny at a settings-source rule — the
|
|
133
|
+
* authoritative, bypass-immune layer. `--disallowedTools` alone (a `cliArg`
|
|
134
|
+
* deny) was observed to leak under `--dangerously-skip-permissions`, so the
|
|
135
|
+
* deny is carried here too. Both consumers use both returned fields: the flag
|
|
136
|
+
* `--settings` JSON (covers non-CI runs) writes the whole object, and
|
|
137
|
+
* `buildManagedSettings` (CI, /etc managed settings) spreads `hooks` and folds
|
|
138
|
+
* `permissions.deny` into its richer deny list.
|
|
139
|
+
*/
|
|
140
|
+
export function buildClaudePretoolGateSettings(
|
|
141
|
+
scriptAbsolutePath: string,
|
|
142
|
+
execToolDenyRules: string[],
|
|
143
|
+
): {
|
|
144
|
+
hooks: {
|
|
145
|
+
PreToolUse: Array<{
|
|
146
|
+
matcher: string;
|
|
147
|
+
hooks: Array<{ type: "command"; command: string; timeout?: number }>;
|
|
148
|
+
}>;
|
|
149
|
+
};
|
|
150
|
+
permissions: { deny: string[] };
|
|
151
|
+
} {
|
|
152
|
+
return {
|
|
153
|
+
hooks: {
|
|
154
|
+
PreToolUse: [
|
|
155
|
+
{
|
|
156
|
+
matcher: "^mcp__terramend__",
|
|
157
|
+
hooks: [
|
|
158
|
+
{
|
|
159
|
+
type: "command",
|
|
160
|
+
// shell-quote-safe because tmpdir paths created via
|
|
161
|
+
// node:fs.mkdtempSync don't contain spaces, but pass via a
|
|
162
|
+
// literal that node parses as a single argv entry to be
|
|
163
|
+
// defensive against future tmpdir layout changes.
|
|
164
|
+
command: `node ${JSON.stringify(scriptAbsolutePath)}`,
|
|
165
|
+
timeout: 5,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
permissions: { deny: execToolDenyRules },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { type GateServerHandle, startGateServer } from "#app/agents/gateServer";
|
|
3
|
+
import type { AgentRunContext, PostRunIssues } from "#app/agents/shared";
|
|
4
|
+
|
|
5
|
+
const collectPostRunIssuesMock = vi.fn();
|
|
6
|
+
const shouldRunReflectionMock = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock("#app/agents/postRun", () => ({
|
|
9
|
+
collectPostRunIssues: (ctx: unknown, opts: unknown) => collectPostRunIssuesMock(ctx, opts),
|
|
10
|
+
buildPostRunPrompt: (issues: PostRunIssues) => `post-run prompt: ${JSON.stringify(issues)}`,
|
|
11
|
+
buildLearningsReflectionPrompt: (path: string) => `reflection prompt for ${path}`,
|
|
12
|
+
shouldRunReflection: (mode: string | undefined) => shouldRunReflectionMock(mode),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const noIssues: PostRunIssues = {};
|
|
16
|
+
const dirtyIssues: PostRunIssues = { dirtyTree: "M src/main.tf" };
|
|
17
|
+
|
|
18
|
+
function makeCtx(
|
|
19
|
+
toolState: { learningsFilePath?: string; selectedMode?: string } = {},
|
|
20
|
+
): AgentRunContext {
|
|
21
|
+
return { toolState } as unknown as AgentRunContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let handle: GateServerHandle | undefined;
|
|
25
|
+
|
|
26
|
+
async function startServer(ctx: AgentRunContext): Promise<GateServerHandle> {
|
|
27
|
+
handle = await startGateServer(ctx);
|
|
28
|
+
return handle;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function gateFetch(server: GateServerHandle, init?: RequestInit): Promise<Response> {
|
|
32
|
+
return fetch(server.url, {
|
|
33
|
+
headers: { authorization: `Bearer ${server.token}` },
|
|
34
|
+
...init,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
collectPostRunIssuesMock.mockResolvedValue(noIssues);
|
|
40
|
+
shouldRunReflectionMock.mockReturnValue(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
if (handle) {
|
|
45
|
+
await handle[Symbol.asyncDispose]();
|
|
46
|
+
handle = undefined;
|
|
47
|
+
}
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("gate server routing and auth", () => {
|
|
52
|
+
it("binds a loopback url and a uuid bearer token", async () => {
|
|
53
|
+
const server = await startServer(makeCtx());
|
|
54
|
+
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/gates$/);
|
|
55
|
+
expect(server.token).toMatch(/^[0-9a-f-]{36}$/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns 404 for unknown paths and non-GET methods", async () => {
|
|
59
|
+
const server = await startServer(makeCtx());
|
|
60
|
+
|
|
61
|
+
const wrongPath = await fetch(server.url.replace("/gates", "/other"), {
|
|
62
|
+
headers: { authorization: `Bearer ${server.token}` },
|
|
63
|
+
});
|
|
64
|
+
expect(wrongPath.status).toBe(404);
|
|
65
|
+
|
|
66
|
+
const wrongMethod = await gateFetch(server, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { authorization: `Bearer ${server.token}` },
|
|
69
|
+
});
|
|
70
|
+
expect(wrongMethod.status).toBe(404);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects missing or wrong bearer tokens without consuming budget", async () => {
|
|
74
|
+
collectPostRunIssuesMock.mockResolvedValue(dirtyIssues);
|
|
75
|
+
const server = await startServer(makeCtx());
|
|
76
|
+
|
|
77
|
+
const noAuth = await fetch(server.url);
|
|
78
|
+
expect(noAuth.status).toBe(403);
|
|
79
|
+
|
|
80
|
+
const wrongAuth = await fetch(server.url, {
|
|
81
|
+
headers: { authorization: "Bearer not-the-token" },
|
|
82
|
+
});
|
|
83
|
+
expect(wrongAuth.status).toBe(403);
|
|
84
|
+
|
|
85
|
+
// gate state was never read — the budget is untouched
|
|
86
|
+
expect(collectPostRunIssuesMock).not.toHaveBeenCalled();
|
|
87
|
+
|
|
88
|
+
// all MAX_POST_RUN_RETRIES blocks are still available afterwards
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
const res = await gateFetch(server);
|
|
91
|
+
expect(await res.json()).toMatchObject({ block: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("gate decisions", () => {
|
|
97
|
+
it("allows the stop when gates are clean and no reflection is pending", async () => {
|
|
98
|
+
const server = await startServer(makeCtx());
|
|
99
|
+
const res = await gateFetch(server);
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
102
|
+
expect(await res.json()).toEqual({ block: false });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("blocks with the combined gate prompt while issues persist", async () => {
|
|
106
|
+
collectPostRunIssuesMock.mockResolvedValue(dirtyIssues);
|
|
107
|
+
const server = await startServer(makeCtx());
|
|
108
|
+
|
|
109
|
+
const res = await gateFetch(server);
|
|
110
|
+
const body = (await res.json()) as { block: boolean; reason: string };
|
|
111
|
+
expect(body.block).toBe(true);
|
|
112
|
+
expect(body.reason).toContain("post-run prompt:");
|
|
113
|
+
expect(body.reason).toContain("M src/main.tf");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("allows the stop once the retry budget is exhausted (terminal hard-fail)", async () => {
|
|
117
|
+
collectPostRunIssuesMock.mockResolvedValue(dirtyIssues);
|
|
118
|
+
const server = await startServer(makeCtx());
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
const res = await gateFetch(server);
|
|
122
|
+
expect(await res.json()).toMatchObject({ block: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const exhausted = await gateFetch(server);
|
|
126
|
+
expect(await exhausted.json()).toEqual({ block: false });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("nudges summaryStale exactly once, then skips it on later collections", async () => {
|
|
130
|
+
collectPostRunIssuesMock
|
|
131
|
+
.mockResolvedValueOnce({ summaryStale: { filePath: "/tmp/summary.md" } })
|
|
132
|
+
.mockResolvedValue(noIssues);
|
|
133
|
+
const server = await startServer(makeCtx());
|
|
134
|
+
|
|
135
|
+
const first = await gateFetch(server);
|
|
136
|
+
expect(await first.json()).toMatchObject({ block: true });
|
|
137
|
+
expect(collectPostRunIssuesMock).toHaveBeenLastCalledWith(expect.anything(), {
|
|
138
|
+
skipSummaryStale: false,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const second = await gateFetch(server);
|
|
142
|
+
expect(await second.json()).toEqual({ block: false });
|
|
143
|
+
expect(collectPostRunIssuesMock).toHaveBeenLastCalledWith(expect.anything(), {
|
|
144
|
+
skipSummaryStale: true,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("delivers the reflection nudge once when gates are clean", async () => {
|
|
149
|
+
shouldRunReflectionMock.mockReturnValue(true);
|
|
150
|
+
const server = await startServer(
|
|
151
|
+
makeCtx({ learningsFilePath: "/tmp/learnings.md", selectedMode: "Remediate" }),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const first = await gateFetch(server);
|
|
155
|
+
const body = (await first.json()) as { block: boolean; reason: string };
|
|
156
|
+
expect(body.block).toBe(true);
|
|
157
|
+
expect(body.reason).toBe("reflection prompt for /tmp/learnings.md");
|
|
158
|
+
expect(shouldRunReflectionMock).toHaveBeenCalledWith("Remediate");
|
|
159
|
+
|
|
160
|
+
// one-shot: the second stop is allowed
|
|
161
|
+
const second = await gateFetch(server);
|
|
162
|
+
expect(await second.json()).toEqual({ block: false });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("skips reflection when no learnings file was seeded", async () => {
|
|
166
|
+
shouldRunReflectionMock.mockReturnValue(true);
|
|
167
|
+
const server = await startServer(makeCtx({ selectedMode: "Remediate" }));
|
|
168
|
+
|
|
169
|
+
const res = await gateFetch(server);
|
|
170
|
+
expect(await res.json()).toEqual({ block: false });
|
|
171
|
+
expect(shouldRunReflectionMock).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("skips reflection when the selected mode opts out", async () => {
|
|
175
|
+
shouldRunReflectionMock.mockReturnValue(false);
|
|
176
|
+
const server = await startServer(
|
|
177
|
+
makeCtx({ learningsFilePath: "/tmp/learnings.md", selectedMode: "Review" }),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const res = await gateFetch(server);
|
|
181
|
+
expect(await res.json()).toEqual({ block: false });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("allows the stop when the gate collection itself throws", async () => {
|
|
185
|
+
collectPostRunIssuesMock.mockRejectedValue(new Error("github 500"));
|
|
186
|
+
const server = await startServer(makeCtx());
|
|
187
|
+
|
|
188
|
+
const res = await gateFetch(server);
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
expect(await res.json()).toEqual({ block: false });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("gate server disposal", () => {
|
|
195
|
+
it("stops accepting connections after dispose", async () => {
|
|
196
|
+
const server = await startServer(makeCtx());
|
|
197
|
+
const url = server.url;
|
|
198
|
+
await server[Symbol.asyncDispose]();
|
|
199
|
+
handle = undefined;
|
|
200
|
+
|
|
201
|
+
const err = await fetch(url).catch((e: unknown) => e);
|
|
202
|
+
expect(err).toBeInstanceOf(Error);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate server — localhost HTTP sidecar that returns Stop-hook decisions for
|
|
3
|
+
* the Claude harness. the managed Stop hook (see `action/agents/claude.ts`)
|
|
4
|
+
* curls this server on every stop and writes back the resulting
|
|
5
|
+
* `{decision: "block", reason}` to inject a follow-up turn inside the live
|
|
6
|
+
* `queryLoop`, replacing the pre-PR-#795 `--resume <sessionId>` subprocess.
|
|
7
|
+
*
|
|
8
|
+
* the server captures `ctx` by closure so every request reads the latest
|
|
9
|
+
* mutations from MCP tool callbacks (which run in the same process). the
|
|
10
|
+
* counters live here, not on `toolState`, because gate-retry budget and
|
|
11
|
+
* reflection one-shot are harness concerns the literal `toolState` design
|
|
12
|
+
* rule (see `action/toolState.ts`) explicitly excludes.
|
|
13
|
+
*
|
|
14
|
+
* decision policy mirrors the old `runPostRunRetryLoop`:
|
|
15
|
+
* - gates dirty + budget remaining → block with combined gate prompt
|
|
16
|
+
* - gates clean + reflection pending → block with reflection prompt
|
|
17
|
+
* - otherwise → allow stop
|
|
18
|
+
* `summaryStale` is a one-shot nudge; once delivered the gate is suppressed
|
|
19
|
+
* so a deliberately-unchanged file doesn't burn the retry budget. when the
|
|
20
|
+
* budget is exhausted with hard-fail gates still failing, we allow the stop
|
|
21
|
+
* so `finalizeAgentResult` can render the terminal `AgentResult.success =
|
|
22
|
+
* false` — that state cannot be set from a stdout-only hook.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { createServer } from "node:http";
|
|
26
|
+
import {
|
|
27
|
+
buildLearningsReflectionPrompt,
|
|
28
|
+
buildPostRunPrompt,
|
|
29
|
+
collectPostRunIssues,
|
|
30
|
+
shouldRunReflection,
|
|
31
|
+
} from "#app/agents/postRun";
|
|
32
|
+
import { type AgentRunContext, hasPostRunIssues, MAX_POST_RUN_RETRIES } from "#app/agents/shared";
|
|
33
|
+
import { log } from "#app/utils/cli";
|
|
34
|
+
|
|
35
|
+
export interface GateServerHandle {
|
|
36
|
+
url: string;
|
|
37
|
+
// bearer token the Stop hook must present. Passed to the agent process via a
|
|
38
|
+
// `_TOKEN`-suffixed env var so filterEnv() strips it from the agent's shell
|
|
39
|
+
// sandbox — only the real Stop hook (a child of the agent process, inheriting
|
|
40
|
+
// its full env) can read it. Without this, any loopback caller (incl. the
|
|
41
|
+
// agent) could poison the retry budget by hitting GET /gates.
|
|
42
|
+
token: string;
|
|
43
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function startGateServer(ctx: AgentRunContext): Promise<GateServerHandle> {
|
|
47
|
+
let blockCount = 0;
|
|
48
|
+
let reflectionDelivered = false;
|
|
49
|
+
let summaryStaleNudged = false;
|
|
50
|
+
const token = randomUUID();
|
|
51
|
+
|
|
52
|
+
const server = createServer((req, res) => {
|
|
53
|
+
void (async () => {
|
|
54
|
+
if (req.method !== "GET" || req.url !== "/gates") {
|
|
55
|
+
res.writeHead(404).end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Authenticate before reading state or mutating counters — an unauthorized
|
|
59
|
+
// request must not consume the retry budget.
|
|
60
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
61
|
+
res.writeHead(403).end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const issues = await collectPostRunIssues(ctx, { skipSummaryStale: summaryStaleNudged });
|
|
66
|
+
if (hasPostRunIssues(issues)) {
|
|
67
|
+
if (blockCount >= MAX_POST_RUN_RETRIES) {
|
|
68
|
+
log.info("» gate-server: retry budget exhausted, allowing stop for terminal hard-fail");
|
|
69
|
+
res.writeHead(200, { "content-type": "application/json" }).end('{"block":false}');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (issues.summaryStale) summaryStaleNudged = true;
|
|
73
|
+
blockCount++;
|
|
74
|
+
const reason = buildPostRunPrompt(issues);
|
|
75
|
+
log.info(`» gate-server: blocking (attempt ${blockCount}/${MAX_POST_RUN_RETRIES})`);
|
|
76
|
+
res
|
|
77
|
+
.writeHead(200, { "content-type": "application/json" })
|
|
78
|
+
.end(JSON.stringify({ block: true, reason }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const learningsPath = ctx.toolState.learningsFilePath;
|
|
82
|
+
if (
|
|
83
|
+
!reflectionDelivered &&
|
|
84
|
+
learningsPath &&
|
|
85
|
+
shouldRunReflection(ctx.toolState.selectedMode)
|
|
86
|
+
) {
|
|
87
|
+
reflectionDelivered = true;
|
|
88
|
+
const reason = buildLearningsReflectionPrompt(learningsPath);
|
|
89
|
+
log.info("» gate-server: delivering reflection nudge");
|
|
90
|
+
res
|
|
91
|
+
.writeHead(200, { "content-type": "application/json" })
|
|
92
|
+
.end(JSON.stringify({ block: true, reason }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
res.writeHead(200, { "content-type": "application/json" }).end('{"block":false}');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
log.warning(`» gate-server handler error: ${msg}`);
|
|
99
|
+
// never block claude on a server-side fault — allow the stop and
|
|
100
|
+
// let the harness's terminal hard-fail path own the decision.
|
|
101
|
+
res.writeHead(200, { "content-type": "application/json" }).end('{"block":false}');
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await new Promise<void>((resolve, reject) => {
|
|
107
|
+
server.once("error", reject);
|
|
108
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
109
|
+
});
|
|
110
|
+
const addr = server.address();
|
|
111
|
+
if (!addr || typeof addr === "string") {
|
|
112
|
+
throw new Error("gate-server: failed to bind localhost port");
|
|
113
|
+
}
|
|
114
|
+
const url = `http://127.0.0.1:${addr.port}/gates`;
|
|
115
|
+
log.debug(`» gate-server listening at ${url}`);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
url,
|
|
119
|
+
token,
|
|
120
|
+
[Symbol.asyncDispose]: async () => {
|
|
121
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { claude } from "#app/agents/claude";
|
|
2
|
+
// In-process OpenCode harness, adapted to opencode-ai >=1.14.x SDK-v2 /
|
|
3
|
+
// Effect-ts CLI rewrite. (The legacy CLI-subprocess harness was removed; see
|
|
4
|
+
// git history if a revert is ever needed.)
|
|
5
|
+
import { opencode } from "#app/agents/opencode";
|
|
6
|
+
import type { Agent } from "#app/agents/shared";
|
|
7
|
+
|
|
8
|
+
export type { Agent, AgentUsage } from "#app/agents/shared";
|
|
9
|
+
|
|
10
|
+
export const agents = { claude, opencode } satisfies Record<string, Agent>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Canonical native-FS-tool deny set shared by the OpenCode and Claude harnesses.
|
|
2
|
+
//
|
|
3
|
+
// The agent's NATIVE FS tools (Read/Write/Edit/Glob/Grep) run in the agent
|
|
4
|
+
// process OUTSIDE the bash mount-namespace sandbox (FS_MOUNTS in
|
|
5
|
+
// action/mcp/shell.ts), so they need an independent deny — without it a
|
|
6
|
+
// prompt-injected agent could plant a git filter or hook via the native edit
|
|
7
|
+
// tool, bypassing the shell sandbox entirely. See wiki/security.md "Native
|
|
8
|
+
// Tool Filesystem Sandbox".
|
|
9
|
+
//
|
|
10
|
+
// WRITES are denied across ALL of .git, READS only on .git/config:
|
|
11
|
+
// - nothing legitimately WRITES under .git via native tools — real commits
|
|
12
|
+
// go through the MCP git tools, which run in the action process OUTSIDE
|
|
13
|
+
// this permission gate. so a blanket write-deny is free, and it robustly
|
|
14
|
+
// covers every code-exec surface an enumerated list would miss:
|
|
15
|
+
// .git/config, .git/config.worktree, .git/modules/*/config (all carry the
|
|
16
|
+
// same core.hooksPath / clean+smudge filter / alias / credential.helper
|
|
17
|
+
// exec vectors) plus .git/hooks/* and .git/info/attributes. the deny also
|
|
18
|
+
// covers the `.git` gitfile itself and nested `*/.git` gitfiles (worktree /
|
|
19
|
+
// submodule layouts), whose `gitdir:` pointer is the same exec surface.
|
|
20
|
+
// - READS stay narrow (.git/config only) because over-blocking .git reads
|
|
21
|
+
// would break legit native orientation reads (.git/HEAD, refs), and the
|
|
22
|
+
// read threat is low — ASKPASS keeps live tokens out of .git/config.
|
|
23
|
+
//
|
|
24
|
+
// The two agents express denies differently, so the surfaces are encoded once
|
|
25
|
+
// here and formatted per-agent:
|
|
26
|
+
// - OpenCode: worktree-relative patterns under the per-tool `read`/`edit`
|
|
27
|
+
// permission keys (Wildcard dialect: `*` is regex `.*`, matching `/`
|
|
28
|
+
// recursively). The `external_directory` gate can't restrict within-project
|
|
29
|
+
// paths (it short-circuits inside the repo root via Instance.containsPath),
|
|
30
|
+
// and the `grep`/`glob` permissions match the search pattern rather than a
|
|
31
|
+
// filepath — so only `read` and `edit` can path-deny within the project.
|
|
32
|
+
// - Claude: gitignore-style globs (`**` = recursive) per (tool, path) in
|
|
33
|
+
// managed-settings `permissions.deny`.
|
|
34
|
+
|
|
35
|
+
/** worktree-relative blanket WRITE deny for the entire `.git` tree, in
|
|
36
|
+
* OpenCode Wildcard dialect (`*` compiles to regex `.*`, matching `/`
|
|
37
|
+
* recursively — see packages/core/src/util/wildcard.ts). spread into the
|
|
38
|
+
* `edit` ruleset after a `"*": "allow"` baseline — `evaluate` is
|
|
39
|
+
* last-match-wins by key order, so the deny keys must follow the wildcard
|
|
40
|
+
* allow.
|
|
41
|
+
*
|
|
42
|
+
* four patterns, because the root-anchored descendants glob only matches
|
|
43
|
+
* paths under a root `.git` *directory* — it misses `.git` when it's a gitfile
|
|
44
|
+
* (worktree / submodule layouts: a regular file whose `gitdir:` line redirects
|
|
45
|
+
* git metadata) and misses nested gitfiles (a `.git` inside a subdirectory).
|
|
46
|
+
* rewriting either pointer is the same code-exec surface (`core.hooksPath`,
|
|
47
|
+
* clean/smudge filters, credential.helper) the blanket deny exists to seal, so
|
|
48
|
+
* we cover the gitfile itself and any nested `.git` too. */
|
|
49
|
+
export const GIT_NATIVE_WRITE_DENY_OPENCODE: Record<string, "deny"> = {
|
|
50
|
+
".git": "deny",
|
|
51
|
+
".git/*": "deny",
|
|
52
|
+
"*/.git": "deny",
|
|
53
|
+
"*/.git/*": "deny",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** worktree-relative narrow READ deny (`.git/config` only), in OpenCode
|
|
57
|
+
* Wildcard dialect. spread into the `read` ruleset after the `"*": "allow"`
|
|
58
|
+
* baseline. */
|
|
59
|
+
export const GIT_NATIVE_READ_DENY_OPENCODE: Record<string, "deny"> = {
|
|
60
|
+
".git/config": "deny",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** native FS tools Claude exposes that can read or enumerate files. */
|
|
64
|
+
const CLAUDE_READ_TOOLS = ["Read", "Grep", "Glob"] as const;
|
|
65
|
+
|
|
66
|
+
/** Claude `permissions.deny` entries for the blanket `.git` WRITE deny —
|
|
67
|
+
* mirrors {@link GIT_NATIVE_WRITE_DENY_OPENCODE}. `**` is recursive. the exact
|
|
68
|
+
* `.git` entry plus the recursive-prefix gitfile entry cover the gitfile
|
|
69
|
+
* pointer (root + nested) that the root-anchored descendants glob alone misses;
|
|
70
|
+
* the recursive-prefix descendants entry covers nested gitdirs. */
|
|
71
|
+
export const GIT_NATIVE_WRITE_DENY_CLAUDE: string[] = [
|
|
72
|
+
"Edit(.git)",
|
|
73
|
+
"Edit(.git/**)",
|
|
74
|
+
"Edit(**/.git)",
|
|
75
|
+
"Edit(**/.git/**)",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Claude `permissions.deny` entries for the narrow `.git/config` READ deny,
|
|
79
|
+
* one per read/enumerate tool — mirrors {@link GIT_NATIVE_READ_DENY_OPENCODE}. */
|
|
80
|
+
export const GIT_NATIVE_READ_DENY_CLAUDE: string[] = CLAUDE_READ_TOOLS.map(
|
|
81
|
+
(tool) => `${tool}(.git/config)`,
|
|
82
|
+
);
|