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,175 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
detectEnvironmentTwins,
|
|
7
|
+
discoverTerraformRoots,
|
|
8
|
+
isRootModuleHcl,
|
|
9
|
+
TerraformRootsTool,
|
|
10
|
+
} from "#app/mcp/roots";
|
|
11
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
12
|
+
|
|
13
|
+
describe("isRootModuleHcl", () => {
|
|
14
|
+
it("detects a provider configuration block (root)", () => {
|
|
15
|
+
expect(isRootModuleHcl('provider "aws" {\n region = "eu-west-2"\n}')).toEqual({
|
|
16
|
+
hasBackend: false,
|
|
17
|
+
hasProviderConfig: true,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("detects a backend block (root)", () => {
|
|
22
|
+
expect(isRootModuleHcl('terraform {\n backend "s3" {}\n}').hasBackend).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does NOT treat a child module's required_providers as a root", () => {
|
|
26
|
+
const moduleHcl = `terraform {
|
|
27
|
+
required_providers {
|
|
28
|
+
aws = { source = "hashicorp/aws", version = ">= 5.0" }
|
|
29
|
+
}
|
|
30
|
+
}`;
|
|
31
|
+
expect(isRootModuleHcl(moduleHcl)).toEqual({ hasBackend: false, hasProviderConfig: false });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("discoverTerraformRoots (multi-root, hepcare layout)", () => {
|
|
36
|
+
let root: string;
|
|
37
|
+
|
|
38
|
+
beforeAll(() => {
|
|
39
|
+
root = mkdtempSync(join(tmpdir(), "tf-roots-"));
|
|
40
|
+
// root #1: terraform/ with backend + provider config
|
|
41
|
+
mkdirSync(join(root, "terraform"), { recursive: true });
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(root, "terraform", "providers.tf"),
|
|
44
|
+
'terraform {\n backend "s3" {}\n}\nprovider "aws" {\n region = "eu-west-2"\n}',
|
|
45
|
+
);
|
|
46
|
+
writeFileSync(join(root, "terraform", "main.tf"), 'resource "aws_s3_bucket" "b" {}');
|
|
47
|
+
// root #2: terraform/core/ with a provider config
|
|
48
|
+
mkdirSync(join(root, "terraform", "core"), { recursive: true });
|
|
49
|
+
writeFileSync(join(root, "terraform", "core", "providers.tf"), 'provider "aws" {}');
|
|
50
|
+
// child module: terraform/modules/x — only required_providers (NOT a root)
|
|
51
|
+
mkdirSync(join(root, "terraform", "modules", "x"), { recursive: true });
|
|
52
|
+
writeFileSync(
|
|
53
|
+
join(root, "terraform", "modules", "x", "versions.tf"),
|
|
54
|
+
'terraform {\n required_providers {\n aws = { source = "hashicorp/aws" }\n }\n}',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
59
|
+
|
|
60
|
+
it("finds the two roots and excludes the child module", () => {
|
|
61
|
+
const roots = discoverTerraformRoots(root);
|
|
62
|
+
expect(roots.map((r) => r.dir)).toEqual(["terraform", "terraform/core"]);
|
|
63
|
+
expect(roots[0]).toMatchObject({ hasBackend: true, hasProviderConfig: true });
|
|
64
|
+
expect(roots[1]).toMatchObject({ hasBackend: false, hasProviderConfig: true });
|
|
65
|
+
expect(roots.some((r) => r.dir.includes("modules"))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns empty when no provider/backend is configured anywhere", () => {
|
|
69
|
+
const empty = mkdtempSync(join(tmpdir(), "tf-noroot-"));
|
|
70
|
+
writeFileSync(join(empty, "main.tf"), 'resource "aws_s3_bucket" "b" {}');
|
|
71
|
+
expect(discoverTerraformRoots(empty)).toEqual([]);
|
|
72
|
+
rmSync(empty, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("detectEnvironmentTwins (§22)", () => {
|
|
77
|
+
it("groups dev/staging/prod stacks that differ only by an env segment", () => {
|
|
78
|
+
const twins = detectEnvironmentTwins([
|
|
79
|
+
"environments/dev",
|
|
80
|
+
"environments/staging",
|
|
81
|
+
"environments/prod",
|
|
82
|
+
]);
|
|
83
|
+
expect(twins).toHaveLength(1);
|
|
84
|
+
expect(twins[0]!.pattern).toBe("environments/{env}");
|
|
85
|
+
expect(twins[0]!.members.map((m) => m.environment)).toEqual(["dev", "prod", "staging"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("matches the LAST env segment so nested paths key correctly", () => {
|
|
89
|
+
const twins = detectEnvironmentTwins(["infra/prod/network", "infra/dev/network"]);
|
|
90
|
+
expect(twins[0]!.pattern).toBe("infra/{env}/network");
|
|
91
|
+
expect(twins[0]!.members.map((m) => m.environment)).toEqual(["dev", "prod"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("detects per-region twins", () => {
|
|
95
|
+
const twins = detectEnvironmentTwins(["stacks/eu-west-2", "stacks/eu-west-1"]);
|
|
96
|
+
expect(twins[0]!.pattern).toBe("stacks/{env}");
|
|
97
|
+
expect(twins[0]!.members.map((m) => m.environment)).toEqual(["eu-west-1", "eu-west-2"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("matches a <env>.tfvars filename", () => {
|
|
101
|
+
const twins = detectEnvironmentTwins(["env/dev.tfvars", "env/prod.tfvars"]);
|
|
102
|
+
expect(twins[0]!.pattern).toBe("env/{env}.tfvars");
|
|
103
|
+
expect(twins[0]!.members.map((m) => m.environment)).toEqual(["dev", "prod"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does NOT group a single environment (needs ≥2 distinct)", () => {
|
|
107
|
+
expect(detectEnvironmentTwins(["environments/prod"])).toEqual([]);
|
|
108
|
+
// same env twice → still one distinct → not a twin set.
|
|
109
|
+
expect(detectEnvironmentTwins(["a/prod", "a/prod"])).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("ignores paths with no env/region segment", () => {
|
|
113
|
+
expect(detectEnvironmentTwins(["modules/vpc", "modules/s3"])).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("TerraformRootsTool", () => {
|
|
118
|
+
async function runRootsTool(cwd: string): Promise<string> {
|
|
119
|
+
const tool = TerraformRootsTool({ payload: { cwd } } as unknown as ToolContext);
|
|
120
|
+
const exec = tool.execute as (
|
|
121
|
+
p: unknown,
|
|
122
|
+
c: unknown,
|
|
123
|
+
) => Promise<{ content: [{ type: "text"; text: string }] }>;
|
|
124
|
+
const result = await exec({}, {});
|
|
125
|
+
return result.content[0].text;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let multiRoot: string;
|
|
129
|
+
let twinRoot: string;
|
|
130
|
+
let singleRoot: string;
|
|
131
|
+
|
|
132
|
+
beforeAll(() => {
|
|
133
|
+
multiRoot = mkdtempSync(join(tmpdir(), "tf-rootstool-"));
|
|
134
|
+
mkdirSync(join(multiRoot, "core"), { recursive: true });
|
|
135
|
+
writeFileSync(join(multiRoot, "main.tf"), 'provider "aws" {}');
|
|
136
|
+
writeFileSync(join(multiRoot, "core", "main.tf"), 'terraform {\n backend "s3" {}\n}');
|
|
137
|
+
|
|
138
|
+
twinRoot = mkdtempSync(join(tmpdir(), "tf-rootstwin-"));
|
|
139
|
+
for (const env of ["dev", "prod"]) {
|
|
140
|
+
mkdirSync(join(twinRoot, "stacks", env), { recursive: true });
|
|
141
|
+
writeFileSync(join(twinRoot, "stacks", env, "main.tf"), 'provider "aws" {}');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
singleRoot = mkdtempSync(join(tmpdir(), "tf-rootsone-"));
|
|
145
|
+
writeFileSync(join(singleRoot, "main.tf"), 'provider "aws" {}');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterAll(() => {
|
|
149
|
+
rmSync(multiRoot, { recursive: true, force: true });
|
|
150
|
+
rmSync(twinRoot, { recursive: true, force: true });
|
|
151
|
+
rmSync(singleRoot, { recursive: true, force: true });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("reports multiple roots with the per-root flags and the multi-root note", async () => {
|
|
155
|
+
const text = await runRootsTool(multiRoot);
|
|
156
|
+
expect(text).toContain("root_count: 2");
|
|
157
|
+
expect(text).toContain("has_backend");
|
|
158
|
+
expect(text).toContain("has_provider_config");
|
|
159
|
+
expect(text).toContain("Multiple roots");
|
|
160
|
+
expect(text).toContain("environment_twins: []");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("reports environment twins among parallel stacks", async () => {
|
|
164
|
+
const text = await runRootsTool(twinRoot);
|
|
165
|
+
expect(text).toContain("root_count: 2");
|
|
166
|
+
expect(text).toContain("stacks/{env}");
|
|
167
|
+
expect(text).toContain("Detected environment twins");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("reports a single root with the single-root note", async () => {
|
|
171
|
+
const text = await runRootsTool(singleRoot);
|
|
172
|
+
expect(text).toContain("root_count: 1");
|
|
173
|
+
expect(text).toContain("Single root (or none detected)");
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/mcp/roots.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import type { LocalToolContext } from "#app/mcp/localContext";
|
|
5
|
+
import { walkTfFiles } from "#app/mcp/modules";
|
|
6
|
+
import { execute, tool } from "#app/mcp/shared";
|
|
7
|
+
import { log } from "#app/utils/cli";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Multi-root awareness. A repo can hold SEVERAL Terraform root modules — the
|
|
11
|
+
* dirs you'd run `terraform init/plan/apply` in — not one. hepcare, for example,
|
|
12
|
+
* has `terraform/` AND `terraform/core/`, plus a child module under
|
|
13
|
+
* `terraform/modules/`. The scanners run recursively, but plan/validate assume a
|
|
14
|
+
* single `cwd`; this surfaces the roots so a run can act per-root.
|
|
15
|
+
*
|
|
16
|
+
* Heuristic (validated against real repos): a ROOT is a dir whose `*.tf`
|
|
17
|
+
* declares a PROVIDER CONFIGURATION (`provider "<name>" { … }`) or a BACKEND
|
|
18
|
+
* (`backend "<type>" { … }`). A CHILD MODULE never configures a provider or a
|
|
19
|
+
* backend (it only declares `required_providers`), so this cleanly separates the
|
|
20
|
+
* two. Pure parsing + a single fs walk; no subprocess.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface TerraformRoot {
|
|
24
|
+
/** repo-relative dir (POSIX); "" for the top-level. */
|
|
25
|
+
dir: string;
|
|
26
|
+
hasBackend: boolean;
|
|
27
|
+
hasProviderConfig: boolean;
|
|
28
|
+
tfFileCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// a provider CONFIGURATION block — `provider "aws" {`. Distinct from a
|
|
32
|
+
// `required_providers` block (which child modules also have). The negative
|
|
33
|
+
// lookbehind avoids matching `required_providers`.
|
|
34
|
+
const PROVIDER_CONFIG = /(?:^|\n)\s*provider\s+"[^"]+"\s*\{/;
|
|
35
|
+
const BACKEND_BLOCK = /(?:^|\n)\s*backend\s+"[^"]+"\s*\{/;
|
|
36
|
+
|
|
37
|
+
/** detect whether some concatenated HCL marks a root module. */
|
|
38
|
+
export function isRootModuleHcl(hcl: string): { hasBackend: boolean; hasProviderConfig: boolean } {
|
|
39
|
+
return {
|
|
40
|
+
hasBackend: BACKEND_BLOCK.test(hcl),
|
|
41
|
+
hasProviderConfig: PROVIDER_CONFIG.test(hcl),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** the directory of a repo-relative file path ("" for a top-level file). */
|
|
46
|
+
function dirOf(file: string): string {
|
|
47
|
+
const i = file.lastIndexOf("/");
|
|
48
|
+
return i === -1 ? "" : file.slice(0, i);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Discover the Terraform root modules under `cwd`. Walks `*.tf` recursively
|
|
53
|
+
* (skipping cache/VCS dirs via walkTfFiles), groups by directory, and keeps the
|
|
54
|
+
* dirs that configure a provider or a backend. Sorted by dir. An empty result
|
|
55
|
+
* means no obvious root was found — the caller falls back to scanning `cwd`
|
|
56
|
+
* itself as a single root.
|
|
57
|
+
*/
|
|
58
|
+
export function discoverTerraformRoots(cwd: string): TerraformRoot[] {
|
|
59
|
+
const byDir = new Map<string, { files: string[]; hcl: string }>();
|
|
60
|
+
for (const f of walkTfFiles(cwd)) {
|
|
61
|
+
const dir = dirOf(f);
|
|
62
|
+
const entry = byDir.get(dir) ?? { files: [], hcl: "" };
|
|
63
|
+
entry.files.push(f);
|
|
64
|
+
try {
|
|
65
|
+
entry.hcl += `${readFileSync(join(cwd, f), "utf8")}\n`;
|
|
66
|
+
} catch {
|
|
67
|
+
/* skip unreadable */
|
|
68
|
+
}
|
|
69
|
+
byDir.set(dir, entry);
|
|
70
|
+
}
|
|
71
|
+
const roots: TerraformRoot[] = [];
|
|
72
|
+
for (const [dir, entry] of byDir) {
|
|
73
|
+
const { hasBackend, hasProviderConfig } = isRootModuleHcl(entry.hcl);
|
|
74
|
+
if (hasBackend || hasProviderConfig) {
|
|
75
|
+
roots.push({ dir, hasBackend, hasProviderConfig, tfFileCount: entry.files.length });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return roots.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- §22 environment / region fan-out --------------------------------------
|
|
82
|
+
|
|
83
|
+
// tokens that mark an environment twin when they appear as a whole path segment
|
|
84
|
+
// or a `<token>.tfvars` filename. Lowercased; matched exactly against a segment.
|
|
85
|
+
const ENV_TOKENS = new Set([
|
|
86
|
+
"dev",
|
|
87
|
+
"develop",
|
|
88
|
+
"development",
|
|
89
|
+
"stg",
|
|
90
|
+
"stage",
|
|
91
|
+
"staging",
|
|
92
|
+
"prod",
|
|
93
|
+
"prd",
|
|
94
|
+
"production",
|
|
95
|
+
"test",
|
|
96
|
+
"testing",
|
|
97
|
+
"qa",
|
|
98
|
+
"uat",
|
|
99
|
+
"sandbox",
|
|
100
|
+
"sbx",
|
|
101
|
+
"preprod",
|
|
102
|
+
"pre-prod",
|
|
103
|
+
"preproduction",
|
|
104
|
+
"demo",
|
|
105
|
+
"nonprod",
|
|
106
|
+
"non-prod",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// AWS-style region segment, e.g. `eu-west-2`, `eu-west-1` — the other common
|
|
110
|
+
// fan-out axis (the same stack replicated per region).
|
|
111
|
+
const REGION_RE = /^[a-z]{2}-[a-z]+-\d$/;
|
|
112
|
+
|
|
113
|
+
export interface EnvironmentTwinGroup {
|
|
114
|
+
/** the shared path shape with the env/region segment replaced by `{env}`. */
|
|
115
|
+
pattern: string;
|
|
116
|
+
/** the matched twins: each a dir + the environment/region token it carries. */
|
|
117
|
+
members: { dir: string; environment: string }[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Detect environment/region TWINS among a set of repo-relative paths (root dirs
|
|
122
|
+
* and/or `*.tfvars` files): parallel stacks that differ only by an environment
|
|
123
|
+
* (`dev`/`staging`/`prod`/…) or region (`eu-west-2`) segment. A fix applied to
|
|
124
|
+
* one should usually be offered for its twins too (§22 — backport / fan-out).
|
|
125
|
+
*
|
|
126
|
+
* For each path, finds the LAST segment that is an env token or a region (a
|
|
127
|
+
* `<env>.tfvars` file counts via its basename), replaces it with `{env}` to form
|
|
128
|
+
* a pattern, and groups by that pattern. Only groups with ≥2 DISTINCT
|
|
129
|
+
* environments are returned (a single match isn't a twin set). Pure +
|
|
130
|
+
* deterministic (sorted).
|
|
131
|
+
*/
|
|
132
|
+
export function detectEnvironmentTwins(paths: string[]): EnvironmentTwinGroup[] {
|
|
133
|
+
const byPattern = new Map<string, Map<string, string>>(); // pattern → env → dir
|
|
134
|
+
for (const raw of paths) {
|
|
135
|
+
const path = raw.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
136
|
+
const segments = path.split("/").filter(Boolean);
|
|
137
|
+
// a `<env>.tfvars` filename: treat the basename (sans .tfvars) as a segment.
|
|
138
|
+
const last = segments[segments.length - 1] ?? "";
|
|
139
|
+
if (last.endsWith(".tfvars")) {
|
|
140
|
+
segments[segments.length - 1] = last.slice(0, -".tfvars".length);
|
|
141
|
+
}
|
|
142
|
+
// find the LAST env/region segment (so `infra/prod/network` keys on `prod`).
|
|
143
|
+
let idx = -1;
|
|
144
|
+
let token = "";
|
|
145
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
146
|
+
const seg = segments[i]!.toLowerCase();
|
|
147
|
+
if (ENV_TOKENS.has(seg) || REGION_RE.test(seg)) {
|
|
148
|
+
idx = i;
|
|
149
|
+
token = seg;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (idx === -1) continue;
|
|
154
|
+
const patternSegs = [...segments];
|
|
155
|
+
patternSegs[idx] = "{env}";
|
|
156
|
+
const pattern = patternSegs.join("/") + (last.endsWith(".tfvars") ? ".tfvars" : "");
|
|
157
|
+
const map = byPattern.get(pattern) ?? new Map<string, string>();
|
|
158
|
+
if (!map.has(token)) map.set(token, path);
|
|
159
|
+
byPattern.set(pattern, map);
|
|
160
|
+
}
|
|
161
|
+
const groups: EnvironmentTwinGroup[] = [];
|
|
162
|
+
for (const [pattern, envMap] of byPattern) {
|
|
163
|
+
if (envMap.size < 2) continue;
|
|
164
|
+
const members = [...envMap.entries()]
|
|
165
|
+
.map(([environment, dir]) => ({ dir, environment }))
|
|
166
|
+
.sort((a, b) => a.environment.localeCompare(b.environment));
|
|
167
|
+
groups.push({ pattern, members });
|
|
168
|
+
}
|
|
169
|
+
return groups.sort((a, b) => a.pattern.localeCompare(b.pattern));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const TerraformRootsParams = type({});
|
|
173
|
+
|
|
174
|
+
export function TerraformRootsTool(ctx: LocalToolContext) {
|
|
175
|
+
return tool({
|
|
176
|
+
name: "terraform_roots",
|
|
177
|
+
description:
|
|
178
|
+
"Discover the Terraform ROOT modules in the repo — the dirs you'd run `terraform init/plan/apply` in " +
|
|
179
|
+
"(they configure a `provider` or a `backend`), as opposed to child modules under `modules/`. A repo " +
|
|
180
|
+
"can have several (e.g. `terraform/` and `terraform/core/`). `terraform_scan` already scans the whole " +
|
|
181
|
+
"tree, but `terraform_plan`/`terraform_validate` act on one dir — run them once PER ROOT (set the " +
|
|
182
|
+
"`cwd` accordingly) when there is more than one, so each root's real-world effect is checked. Returns " +
|
|
183
|
+
"an empty list when no root is detected (then treat the scan `cwd` as the single root).",
|
|
184
|
+
parameters: TerraformRootsParams,
|
|
185
|
+
execute: execute(async () => {
|
|
186
|
+
const cwd = ctx.payload.cwd ?? process.cwd();
|
|
187
|
+
const roots = discoverTerraformRoots(cwd);
|
|
188
|
+
// §22 — parallel per-environment/region stacks among the roots; a fix to
|
|
189
|
+
// one twin should usually be offered for the others.
|
|
190
|
+
const twins = detectEnvironmentTwins(roots.map((r) => r.dir).filter(Boolean));
|
|
191
|
+
log.info(
|
|
192
|
+
`» terraform_roots: ${roots.length} root(s) [${roots.map((r) => r.dir || ".").join(", ")}]` +
|
|
193
|
+
(twins.length ? `, ${twins.length} env-twin group(s)` : ""),
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
root_count: roots.length,
|
|
198
|
+
roots: roots.map((r) => ({
|
|
199
|
+
dir: r.dir || ".",
|
|
200
|
+
has_backend: r.hasBackend,
|
|
201
|
+
has_provider_config: r.hasProviderConfig,
|
|
202
|
+
tf_file_count: r.tfFileCount,
|
|
203
|
+
})),
|
|
204
|
+
// §22 — environment/region twin groups (≥2 stacks differing only by an
|
|
205
|
+
// env/region segment). Empty when there are none.
|
|
206
|
+
environment_twins: twins,
|
|
207
|
+
note:
|
|
208
|
+
roots.length > 1
|
|
209
|
+
? "Multiple roots — terraform_plan/terraform_validate run per root automatically. " +
|
|
210
|
+
(twins.length
|
|
211
|
+
? "Detected environment twins: a fix to one stack should usually be offered for its twins (§22)."
|
|
212
|
+
: "")
|
|
213
|
+
: "Single root (or none detected) — the scan cwd is the root.",
|
|
214
|
+
};
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assertTargetInScope, isTargetInScope, recordCreatedTarget } from "#app/mcp/scope";
|
|
3
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
4
|
+
|
|
5
|
+
function makeCtx(opts: { issueNumber?: number; created?: number[] } = {}): ToolContext {
|
|
6
|
+
const event =
|
|
7
|
+
opts.issueNumber === undefined
|
|
8
|
+
? { trigger: "unknown" }
|
|
9
|
+
: { trigger: "pull_request_opened", issue_number: opts.issueNumber, is_pr: true };
|
|
10
|
+
return {
|
|
11
|
+
payload: { event },
|
|
12
|
+
toolState: { createdTargets: new Set<number>(opts.created ?? []) },
|
|
13
|
+
} as unknown as ToolContext;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("scope guard", () => {
|
|
17
|
+
it("allows any target on standalone runs (no triggering issue/PR)", () => {
|
|
18
|
+
const ctx = makeCtx({});
|
|
19
|
+
expect(isTargetInScope(ctx, 999)).toBe(true);
|
|
20
|
+
expect(() => assertTargetInScope(ctx, 999, "comment on")).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("allows the run's triggering issue/PR", () => {
|
|
24
|
+
expect(isTargetInScope(makeCtx({ issueNumber: 5 }), 5)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("blocks a different issue/PR in the same repo", () => {
|
|
28
|
+
const ctx = makeCtx({ issueNumber: 5 });
|
|
29
|
+
expect(isTargetInScope(ctx, 6)).toBe(false);
|
|
30
|
+
expect(() => assertTargetInScope(ctx, 6, "comment on")).toThrow(
|
|
31
|
+
/scoped to #5; refusing to comment on #6/,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("allows a PR/issue the run created", () => {
|
|
36
|
+
expect(isTargetInScope(makeCtx({ issueNumber: 5, created: [50] }), 50)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("recordCreatedTarget widens scope to the new target", () => {
|
|
40
|
+
const ctx = makeCtx({ issueNumber: 5 });
|
|
41
|
+
expect(isTargetInScope(ctx, 77)).toBe(false);
|
|
42
|
+
recordCreatedTarget(ctx, 77);
|
|
43
|
+
expect(isTargetInScope(ctx, 77)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("recordCreatedTarget initializes the set when absent", () => {
|
|
47
|
+
const ctx = {
|
|
48
|
+
payload: { event: { trigger: "issues_opened", issue_number: 1 } },
|
|
49
|
+
toolState: {},
|
|
50
|
+
} as unknown as ToolContext;
|
|
51
|
+
recordCreatedTarget(ctx, 42);
|
|
52
|
+
expect(isTargetInScope(ctx, 42)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("degrades to a no-op when payload/event is missing (defensive)", () => {
|
|
56
|
+
const ctx = { toolState: {} } as unknown as ToolContext;
|
|
57
|
+
expect(isTargetInScope(ctx, 123)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/mcp/scope.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bind the GitHub REST write tools to the issue/PR the run is scoped to.
|
|
5
|
+
*
|
|
6
|
+
* Terramend's threat model treats the agent as semi-trusted: attacker-controlled
|
|
7
|
+
* PR/issue content can prompt-inject it. The git tools already refuse to act on a
|
|
8
|
+
* PR the run was not triggered against (the cross-PR clobber guard in
|
|
9
|
+
* `mcp/git.ts`). These helpers extend the same scoping to the REST write tools
|
|
10
|
+
* (comment, review, labels, PR-body update, review reply, thread resolve) so an
|
|
11
|
+
* injected agent cannot comment on / approve / label an UNRELATED issue or PR in
|
|
12
|
+
* the same repo. The installation token already prevents cross-REPO writes; this
|
|
13
|
+
* closes the remaining cross-issue/PR-within-the-repo gap.
|
|
14
|
+
*
|
|
15
|
+
* In scope = the triggering issue/PR (`event.issue_number`) OR a PR/issue THIS
|
|
16
|
+
* run created (`create_pull_request` / `create_issue`, recorded via
|
|
17
|
+
* `recordCreatedTarget`). A merely checked-out PR is NOT in scope: `checkout_pr`
|
|
18
|
+
* is agent-controlled, so letting it widen write scope would defeat the guard.
|
|
19
|
+
*
|
|
20
|
+
* Standalone runs (workflow_dispatch / CLI: `event.trigger === "unknown"`) carry
|
|
21
|
+
* no triggering issue/PR and therefore no injection surface — there is nothing to
|
|
22
|
+
* bind to, so the guard is a no-op and the operator-supplied target is honored.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** the PR/issue this run was triggered against, or undefined for standalone runs. */
|
|
26
|
+
function scopedTarget(ctx: ToolContext): number | undefined {
|
|
27
|
+
// optional chain: production always sets `payload.event` (resolvePayload
|
|
28
|
+
// defaults event to {trigger:"unknown"}), but degrade to "no scope" if it's
|
|
29
|
+
// somehow absent rather than throwing from a guard.
|
|
30
|
+
return ctx.payload?.event?.issue_number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Record a PR/issue this run created so later body edits / comments / reviews on
|
|
35
|
+
* it pass {@link assertTargetInScope}. Call after a successful
|
|
36
|
+
* `create_pull_request` / `create_issue`.
|
|
37
|
+
*/
|
|
38
|
+
export function recordCreatedTarget(ctx: ToolContext, target: number): void {
|
|
39
|
+
ctx.toolState.createdTargets ??= new Set();
|
|
40
|
+
ctx.toolState.createdTargets.add(target);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** true when `target` is the run's triggering issue/PR or one it created. */
|
|
44
|
+
export function isTargetInScope(ctx: ToolContext, target: number): boolean {
|
|
45
|
+
const scoped = scopedTarget(ctx);
|
|
46
|
+
// standalone run — no triggering issue/PR to bind to (no injection surface).
|
|
47
|
+
if (scoped === undefined) return true;
|
|
48
|
+
if (target === scoped) return true;
|
|
49
|
+
return ctx.toolState.createdTargets?.has(target) ?? false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Throw if `target` is neither the run's triggering issue/PR nor one it created.
|
|
54
|
+
* `action` is a short verb phrase used in the error (e.g. "comment on",
|
|
55
|
+
* "submit a review on", "add labels to").
|
|
56
|
+
*/
|
|
57
|
+
export function assertTargetInScope(ctx: ToolContext, target: number, action: string): void {
|
|
58
|
+
if (isTargetInScope(ctx, target)) return;
|
|
59
|
+
const scoped = scopedTarget(ctx);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`blocked: this run is scoped to #${scoped}; refusing to ${action} #${target}. ` +
|
|
62
|
+
`terramend only writes to the issue/PR that triggered the run (or one it opened during the run). ` +
|
|
63
|
+
`if acting on #${target} is intended, trigger a run against #${target}.`,
|
|
64
|
+
);
|
|
65
|
+
}
|