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,720 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { checkoutPrBranch, type PrData } from "#app/mcp/checkout";
|
|
3
|
+
import {
|
|
4
|
+
AUTH_REQUIRED_REDIRECT,
|
|
5
|
+
DeleteBranchTool,
|
|
6
|
+
NOSHELL_BLOCKED_ARGS,
|
|
7
|
+
NOSHELL_BLOCKED_SUBCOMMANDS,
|
|
8
|
+
rejectIfLeadingDash,
|
|
9
|
+
rejectSpecialRef,
|
|
10
|
+
validateTagName,
|
|
11
|
+
} from "#app/mcp/git";
|
|
12
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
13
|
+
|
|
14
|
+
// ─── git tool security tests ────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// the validation function below mirrors the logic in GitTool.execute, but
|
|
17
|
+
// imports the AUTH/NOSHELL tables directly from git.ts so tests don't silently
|
|
18
|
+
// drift if the runtime messages are edited. if the *algorithm* in git.ts
|
|
19
|
+
// changes, validateGitCommand needs to be updated here too.
|
|
20
|
+
|
|
21
|
+
type ShellPermission = "disabled" | "restricted" | "enabled";
|
|
22
|
+
|
|
23
|
+
type ValidateGitParams = {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
shellPermission: ShellPermission;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// matches the arkregex pattern used in the Git schema
|
|
30
|
+
const SUBCOMMAND_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
31
|
+
|
|
32
|
+
// mirrors the validation logic in GitTool.execute
|
|
33
|
+
function validateGitCommand(params: ValidateGitParams): string | null {
|
|
34
|
+
// schema-level regex validation — applies in ALL modes
|
|
35
|
+
if (!SUBCOMMAND_PATTERN.test(params.command)) {
|
|
36
|
+
return `command must be Git subcommand (was "${params.command}")`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const redirect = AUTH_REQUIRED_REDIRECT[params.command];
|
|
40
|
+
if (redirect) {
|
|
41
|
+
return `git ${params.command} requires authentication. ${redirect}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// subcommand and arg blocking only applies when shell is disabled
|
|
45
|
+
if (params.shellPermission === "disabled") {
|
|
46
|
+
const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[params.command];
|
|
47
|
+
if (blocked) {
|
|
48
|
+
return blocked;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const arg of params.args) {
|
|
52
|
+
const isBlocked = NOSHELL_BLOCKED_ARGS.some(
|
|
53
|
+
(flag) => arg === flag || arg.startsWith(`${flag}=`),
|
|
54
|
+
);
|
|
55
|
+
if (isBlocked) {
|
|
56
|
+
return `Blocked: '${arg}' flag can execute arbitrary code and is not allowed.`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null; // no error
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("git tool security - subcommand regex validation", () => {
|
|
65
|
+
it("blocks -c flag as subcommand in ALL modes (alias injection)", () => {
|
|
66
|
+
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
|
|
67
|
+
for (const mode of modes) {
|
|
68
|
+
const error = validateGitCommand({
|
|
69
|
+
command: "-c",
|
|
70
|
+
args: ["alias.x=!evil-command", "x"],
|
|
71
|
+
shellPermission: mode,
|
|
72
|
+
});
|
|
73
|
+
expect(error).toContain("Git subcommand");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("blocks --exec-path as subcommand", () => {
|
|
78
|
+
const error = validateGitCommand({
|
|
79
|
+
command: "--exec-path=/malicious",
|
|
80
|
+
args: ["status"],
|
|
81
|
+
shellPermission: "disabled",
|
|
82
|
+
});
|
|
83
|
+
expect(error).toContain("Git subcommand");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("blocks -C as subcommand (change directory)", () => {
|
|
87
|
+
const error = validateGitCommand({
|
|
88
|
+
command: "-C",
|
|
89
|
+
args: ["/tmp", "init"],
|
|
90
|
+
shellPermission: "disabled",
|
|
91
|
+
});
|
|
92
|
+
expect(error).toContain("Git subcommand");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("blocks --config-env as subcommand", () => {
|
|
96
|
+
const error = validateGitCommand({
|
|
97
|
+
command: "--config-env",
|
|
98
|
+
args: ["core.pager=PATH", "log"],
|
|
99
|
+
shellPermission: "disabled",
|
|
100
|
+
});
|
|
101
|
+
expect(error).toContain("Git subcommand");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("blocks all flags starting with - as subcommand", () => {
|
|
105
|
+
const flags = ["-c", "-C", "-p", "--paginate", "--git-dir", "--work-tree", "--bare"];
|
|
106
|
+
for (const flag of flags) {
|
|
107
|
+
const error = validateGitCommand({
|
|
108
|
+
command: flag,
|
|
109
|
+
args: [],
|
|
110
|
+
shellPermission: "disabled",
|
|
111
|
+
});
|
|
112
|
+
expect(error).toContain("Git subcommand");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("blocks uppercase subcommands", () => {
|
|
117
|
+
const error = validateGitCommand({
|
|
118
|
+
command: "STATUS",
|
|
119
|
+
args: [],
|
|
120
|
+
shellPermission: "disabled",
|
|
121
|
+
});
|
|
122
|
+
expect(error).toContain("Git subcommand");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("blocks subcommands with special characters", () => {
|
|
126
|
+
const bad = ["git;evil", "status$(cmd)", "log|cat", "diff&bg"];
|
|
127
|
+
for (const sub of bad) {
|
|
128
|
+
const error = validateGitCommand({
|
|
129
|
+
command: sub,
|
|
130
|
+
args: [],
|
|
131
|
+
shellPermission: "disabled",
|
|
132
|
+
});
|
|
133
|
+
expect(error).toContain("Git subcommand");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("allows valid subcommands", () => {
|
|
138
|
+
const safe = ["status", "log", "diff", "show", "branch", "tag", "stash", "blame"];
|
|
139
|
+
for (const sub of safe) {
|
|
140
|
+
const error = validateGitCommand({
|
|
141
|
+
command: sub,
|
|
142
|
+
args: [],
|
|
143
|
+
shellPermission: "disabled",
|
|
144
|
+
});
|
|
145
|
+
expect(error).toBeNull();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("allows hyphenated subcommands", () => {
|
|
150
|
+
const safe = ["filter-branch", "update-index", "ls-remote", "ls-files", "rev-parse"];
|
|
151
|
+
for (const sub of safe) {
|
|
152
|
+
const error = validateGitCommand({
|
|
153
|
+
command: sub,
|
|
154
|
+
args: [],
|
|
155
|
+
shellPermission: "enabled",
|
|
156
|
+
});
|
|
157
|
+
expect(error).toBeNull();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
|
163
|
+
it("blocks config in disabled mode", () => {
|
|
164
|
+
const error = validateGitCommand({
|
|
165
|
+
command: "config",
|
|
166
|
+
args: ["core.hooksPath", "./hooks"],
|
|
167
|
+
shellPermission: "disabled",
|
|
168
|
+
});
|
|
169
|
+
expect(error).toContain("git config");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("allows config in restricted mode (agent has shell)", () => {
|
|
173
|
+
const error = validateGitCommand({
|
|
174
|
+
command: "config",
|
|
175
|
+
args: ["filter.evil.clean", "bash -c 'evil'"],
|
|
176
|
+
shellPermission: "restricted",
|
|
177
|
+
});
|
|
178
|
+
expect(error).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("blocks submodule in disabled mode", () => {
|
|
182
|
+
const error = validateGitCommand({
|
|
183
|
+
command: "submodule",
|
|
184
|
+
args: ["add", "https://evil.com/repo.git"],
|
|
185
|
+
shellPermission: "disabled",
|
|
186
|
+
});
|
|
187
|
+
expect(error).toContain("submodule");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("allows submodule in restricted mode", () => {
|
|
191
|
+
const error = validateGitCommand({
|
|
192
|
+
command: "submodule",
|
|
193
|
+
args: ["add", "https://example.com/repo.git"],
|
|
194
|
+
shellPermission: "restricted",
|
|
195
|
+
});
|
|
196
|
+
expect(error).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("blocks rebase in disabled mode", () => {
|
|
200
|
+
const error = validateGitCommand({
|
|
201
|
+
command: "rebase",
|
|
202
|
+
args: ["--exec", "evil-command", "HEAD~1"],
|
|
203
|
+
shellPermission: "disabled",
|
|
204
|
+
});
|
|
205
|
+
expect(error).toContain("rebase");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("allows rebase in restricted mode", () => {
|
|
209
|
+
const error = validateGitCommand({
|
|
210
|
+
command: "rebase",
|
|
211
|
+
args: ["main"],
|
|
212
|
+
shellPermission: "restricted",
|
|
213
|
+
});
|
|
214
|
+
expect(error).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("blocks bisect in disabled mode", () => {
|
|
218
|
+
const error = validateGitCommand({
|
|
219
|
+
command: "bisect",
|
|
220
|
+
args: ["run", "evil-command"],
|
|
221
|
+
shellPermission: "disabled",
|
|
222
|
+
});
|
|
223
|
+
expect(error).toContain("bisect");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("blocks filter-branch in disabled mode", () => {
|
|
227
|
+
const error = validateGitCommand({
|
|
228
|
+
command: "filter-branch",
|
|
229
|
+
args: ["--tree-filter", "evil-command", "HEAD"],
|
|
230
|
+
shellPermission: "disabled",
|
|
231
|
+
});
|
|
232
|
+
expect(error).toContain("filter-branch");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// regression: NOSHELL_BLOCKED_ARGS matches only the long `--extcmd` /
|
|
236
|
+
// `--extcmd=...` forms. `git difftool -x <cmd>` is the short form and
|
|
237
|
+
// slipped through — verified executing a canary via
|
|
238
|
+
// `yes | git difftool -x 'echo PWN' HEAD~1 HEAD` on a real repo.
|
|
239
|
+
// globally blocking `-x` would false-positive on `git cherry-pick -x`
|
|
240
|
+
// (a metadata-appending flag, not code exec), so difftool is blocked
|
|
241
|
+
// at the subcommand level instead.
|
|
242
|
+
it("blocks difftool in disabled mode (closes -x short-form bypass)", () => {
|
|
243
|
+
const error = validateGitCommand({
|
|
244
|
+
command: "difftool",
|
|
245
|
+
args: ["-x", "evil-command", "HEAD~1", "HEAD"],
|
|
246
|
+
shellPermission: "disabled",
|
|
247
|
+
});
|
|
248
|
+
expect(error).toContain("difftool");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("blocks difftool even with --extcmd long form (subcommand-level stops it first)", () => {
|
|
252
|
+
const error = validateGitCommand({
|
|
253
|
+
command: "difftool",
|
|
254
|
+
args: ["--extcmd=evil-command", "HEAD"],
|
|
255
|
+
shellPermission: "disabled",
|
|
256
|
+
});
|
|
257
|
+
expect(error).toContain("difftool");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("blocks mergetool in disabled mode (configured tool commands execute code)", () => {
|
|
261
|
+
const error = validateGitCommand({
|
|
262
|
+
command: "mergetool",
|
|
263
|
+
args: [],
|
|
264
|
+
shellPermission: "disabled",
|
|
265
|
+
});
|
|
266
|
+
expect(error).toContain("mergetool");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("allows blocked subcommands in enabled mode", () => {
|
|
270
|
+
const blocked = [
|
|
271
|
+
"config",
|
|
272
|
+
"submodule",
|
|
273
|
+
"rebase",
|
|
274
|
+
"bisect",
|
|
275
|
+
"filter-branch",
|
|
276
|
+
"difftool",
|
|
277
|
+
"mergetool",
|
|
278
|
+
];
|
|
279
|
+
for (const sub of blocked) {
|
|
280
|
+
const error = validateGitCommand({
|
|
281
|
+
command: sub,
|
|
282
|
+
args: [],
|
|
283
|
+
shellPermission: "enabled",
|
|
284
|
+
});
|
|
285
|
+
expect(error).toBeNull();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("allows blocked subcommands in restricted mode (stripped env is security boundary)", () => {
|
|
290
|
+
const blocked = [
|
|
291
|
+
"config",
|
|
292
|
+
"submodule",
|
|
293
|
+
"rebase",
|
|
294
|
+
"bisect",
|
|
295
|
+
"filter-branch",
|
|
296
|
+
"difftool",
|
|
297
|
+
"mergetool",
|
|
298
|
+
];
|
|
299
|
+
for (const sub of blocked) {
|
|
300
|
+
const error = validateGitCommand({
|
|
301
|
+
command: sub,
|
|
302
|
+
args: [],
|
|
303
|
+
shellPermission: "restricted",
|
|
304
|
+
});
|
|
305
|
+
expect(error).toBeNull();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
|
311
|
+
it("blocks --exec in args (disabled)", () => {
|
|
312
|
+
const error = validateGitCommand({
|
|
313
|
+
command: "log",
|
|
314
|
+
args: ["--exec", "evil-command"],
|
|
315
|
+
shellPermission: "disabled",
|
|
316
|
+
});
|
|
317
|
+
expect(error).toContain("arbitrary code");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("blocks --exec= in args (disabled)", () => {
|
|
321
|
+
const error = validateGitCommand({
|
|
322
|
+
command: "log",
|
|
323
|
+
args: ["--exec=evil-command"],
|
|
324
|
+
shellPermission: "disabled",
|
|
325
|
+
});
|
|
326
|
+
expect(error).toContain("arbitrary code");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("blocks --extcmd in args (disabled) — on a subcommand that isn't blocked at the subcommand level", () => {
|
|
330
|
+
// difftool itself is now blocked at the subcommand level (closes the `-x`
|
|
331
|
+
// short-form bypass), so the arg-level check never runs for difftool in
|
|
332
|
+
// disabled mode. use `log --extcmd=...` to exercise the arg-level code
|
|
333
|
+
// path: `log` isn't in NOSHELL_BLOCKED_SUBCOMMANDS, so validation falls
|
|
334
|
+
// through to the arg scan and the --extcmd block triggers.
|
|
335
|
+
const error = validateGitCommand({
|
|
336
|
+
command: "log",
|
|
337
|
+
args: ["--extcmd=evil-command", "HEAD~1"],
|
|
338
|
+
shellPermission: "disabled",
|
|
339
|
+
});
|
|
340
|
+
expect(error).toContain("arbitrary code");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("blocks --upload-pack in args (disabled)", () => {
|
|
344
|
+
const error = validateGitCommand({
|
|
345
|
+
command: "ls-remote",
|
|
346
|
+
args: ["--upload-pack=evil"],
|
|
347
|
+
shellPermission: "disabled",
|
|
348
|
+
});
|
|
349
|
+
expect(error).toContain("arbitrary code");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("allows --exec in restricted mode (agent has shell)", () => {
|
|
353
|
+
const error = validateGitCommand({
|
|
354
|
+
command: "rebase",
|
|
355
|
+
args: ["--exec", "npm test", "HEAD~1"],
|
|
356
|
+
shellPermission: "restricted",
|
|
357
|
+
});
|
|
358
|
+
expect(error).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("allows --extcmd in restricted mode", () => {
|
|
362
|
+
const error = validateGitCommand({
|
|
363
|
+
command: "difftool",
|
|
364
|
+
args: ["--extcmd=less"],
|
|
365
|
+
shellPermission: "restricted",
|
|
366
|
+
});
|
|
367
|
+
expect(error).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("allows blocked args in enabled mode", () => {
|
|
371
|
+
const error = validateGitCommand({
|
|
372
|
+
command: "difftool",
|
|
373
|
+
args: ["--extcmd=less"],
|
|
374
|
+
shellPermission: "enabled",
|
|
375
|
+
});
|
|
376
|
+
expect(error).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("allows normal args in disabled mode", () => {
|
|
380
|
+
const error = validateGitCommand({
|
|
381
|
+
command: "log",
|
|
382
|
+
args: ["--oneline", "-10", "--format=%H %s"],
|
|
383
|
+
shellPermission: "disabled",
|
|
384
|
+
});
|
|
385
|
+
expect(error).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("does not false-positive on --exclude-standard (not --exec)", () => {
|
|
389
|
+
const error = validateGitCommand({
|
|
390
|
+
command: "ls-files",
|
|
391
|
+
args: ["--exclude-standard"],
|
|
392
|
+
shellPermission: "disabled",
|
|
393
|
+
});
|
|
394
|
+
expect(error).toBeNull();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("does not false-positive on --execute (not --exec=)", () => {
|
|
398
|
+
const error = validateGitCommand({
|
|
399
|
+
command: "log",
|
|
400
|
+
args: ["--execute-something"],
|
|
401
|
+
shellPermission: "disabled",
|
|
402
|
+
});
|
|
403
|
+
expect(error).toBeNull();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("does not false-positive on -c (combined diff format for git log)", () => {
|
|
407
|
+
const error = validateGitCommand({
|
|
408
|
+
command: "log",
|
|
409
|
+
args: ["-c", "--oneline"],
|
|
410
|
+
shellPermission: "disabled",
|
|
411
|
+
});
|
|
412
|
+
expect(error).toBeNull();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe("git tool security - auth redirect", () => {
|
|
417
|
+
it("redirects push in all modes", () => {
|
|
418
|
+
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
|
|
419
|
+
for (const mode of modes) {
|
|
420
|
+
const error = validateGitCommand({
|
|
421
|
+
command: "push",
|
|
422
|
+
args: [],
|
|
423
|
+
shellPermission: mode,
|
|
424
|
+
});
|
|
425
|
+
expect(error).toContain("authentication");
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("redirects fetch", () => {
|
|
430
|
+
const error = validateGitCommand({
|
|
431
|
+
command: "fetch",
|
|
432
|
+
args: [],
|
|
433
|
+
shellPermission: "enabled",
|
|
434
|
+
});
|
|
435
|
+
expect(error).toContain("authentication");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("redirects pull", () => {
|
|
439
|
+
const error = validateGitCommand({
|
|
440
|
+
command: "pull",
|
|
441
|
+
args: [],
|
|
442
|
+
shellPermission: "enabled",
|
|
443
|
+
});
|
|
444
|
+
expect(error).toContain("authentication");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("pull redirect recommends merge (not rebase) regardless of shell mode", () => {
|
|
448
|
+
// F5 regression: the redirect previously suggested "or 'rebase' unless
|
|
449
|
+
// shell is disabled", which was misleading noise under shell=disabled
|
|
450
|
+
// (rebase is blocked by NOSHELL_BLOCKED_SUBCOMMANDS there) and redundant
|
|
451
|
+
// under other modes (agents can invoke rebase directly if they want).
|
|
452
|
+
// the current redirect names only merge — the one alternative that
|
|
453
|
+
// works in every shell mode.
|
|
454
|
+
for (const mode of ["disabled", "restricted", "enabled"] as ShellPermission[]) {
|
|
455
|
+
const error = validateGitCommand({
|
|
456
|
+
command: "pull",
|
|
457
|
+
args: [],
|
|
458
|
+
shellPermission: mode,
|
|
459
|
+
});
|
|
460
|
+
expect(error).toContain("merge");
|
|
461
|
+
expect(error).not.toMatch(/rebase/i);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("redirects clone", () => {
|
|
466
|
+
const error = validateGitCommand({
|
|
467
|
+
command: "clone",
|
|
468
|
+
args: [],
|
|
469
|
+
shellPermission: "enabled",
|
|
470
|
+
});
|
|
471
|
+
expect(error).toContain("authentication");
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ─── dependency install security tests ──────────────────────────────────
|
|
476
|
+
|
|
477
|
+
// mirrors the logic in dependencies.ts startInstallation()
|
|
478
|
+
function shouldIgnoreScripts(shellPermission: ShellPermission): boolean {
|
|
479
|
+
return shellPermission === "disabled";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
describe("git tool security - rejectIfLeadingDash", () => {
|
|
483
|
+
it("rejects refs starting with --", () => {
|
|
484
|
+
expect(() => rejectIfLeadingDash("--upload-pack=evil", "ref")).toThrow(
|
|
485
|
+
/Blocked: ref '--upload-pack=evil' starts with '-'/,
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("rejects refs starting with a single -", () => {
|
|
490
|
+
expect(() => rejectIfLeadingDash("-c", "ref")).toThrow(/starts with '-'/);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("allows normal branch names", () => {
|
|
494
|
+
expect(() => rejectIfLeadingDash("main", "ref")).not.toThrow();
|
|
495
|
+
expect(() => rejectIfLeadingDash("feature/foo", "ref")).not.toThrow();
|
|
496
|
+
expect(() => rejectIfLeadingDash("pull/123/head", "ref")).not.toThrow();
|
|
497
|
+
expect(() => rejectIfLeadingDash("release-1.2", "ref")).not.toThrow();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("allows branch names containing dashes (not leading)", () => {
|
|
501
|
+
expect(() => rejectIfLeadingDash("feat-x", "branchName")).not.toThrow();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("customizes the kind label in the error", () => {
|
|
505
|
+
expect(() => rejectIfLeadingDash("-evil", "branchName")).toThrow(/branchName '-evil'/);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("git tool security - rejectSpecialRef (default-branch bypass)", () => {
|
|
510
|
+
// an agent in restricted mode normally can't push to the default branch —
|
|
511
|
+
// PushBranchTool compares the resolved remoteBranch against defaultBranch
|
|
512
|
+
// and blocks the match. before this guard, passing `branchName:
|
|
513
|
+
// "refs/heads/main"` bypassed the check (the exact-string compare fails
|
|
514
|
+
// because "refs/heads/main" !== "main") while git still pushed to main.
|
|
515
|
+
it("rejects fully-qualified refs/heads/... branch names", () => {
|
|
516
|
+
expect(() => rejectSpecialRef("refs/heads/main", "branch")).toThrow(/fully-qualified ref path/);
|
|
517
|
+
expect(() => rejectSpecialRef("refs/heads/feature/foo", "branch")).toThrow(
|
|
518
|
+
/fully-qualified ref path/,
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("rejects refs/tags/... and refs/remotes/... forms too", () => {
|
|
523
|
+
// push_branch only pushes branches, so every refs/-prefixed form is
|
|
524
|
+
// illegitimate here — no need to whitelist refs/heads/ alone.
|
|
525
|
+
expect(() => rejectSpecialRef("refs/tags/v1", "branch")).toThrow(/fully-qualified ref path/);
|
|
526
|
+
expect(() => rejectSpecialRef("refs/remotes/origin/main", "branch")).toThrow(
|
|
527
|
+
/fully-qualified ref path/,
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("rejects symbolic refs that resolve to arbitrary commits", () => {
|
|
532
|
+
// `git push origin HEAD` and friends pick up whatever commit those refs
|
|
533
|
+
// point at — not what the agent named, and not constrained by the
|
|
534
|
+
// default-branch guard either.
|
|
535
|
+
for (const ref of ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD"]) {
|
|
536
|
+
expect(() => rejectSpecialRef(ref, "branch")).toThrow(/symbolic ref/);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("still rejects leading-dash (inherits rejectIfLeadingDash)", () => {
|
|
541
|
+
expect(() => rejectSpecialRef("-evil", "branch")).toThrow(/starts with '-'/);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("allows bare branch names including ones with slashes", () => {
|
|
545
|
+
for (const b of ["main", "pr-123", "feature/foo", "release/v2", "user/name/topic"]) {
|
|
546
|
+
expect(() => rejectSpecialRef(b, "branch")).not.toThrow();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// refspec syntax: git push accepts `[+]src[:dst]`. without these checks an
|
|
551
|
+
// agent under push:restricted smuggles a full refspec through branchName,
|
|
552
|
+
// and the downstream exact-string default-branch guard misses because the
|
|
553
|
+
// value isn't literally "main". these are the exact attacks the new
|
|
554
|
+
// rejection closes.
|
|
555
|
+
it("rejects ':' (refspec src:dst split that targets main)", () => {
|
|
556
|
+
expect(() => rejectSpecialRef("evil:refs/heads/main", "branch")).toThrow(
|
|
557
|
+
/refspec\/revision syntax/,
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("rejects leading ':' (delete-ref refspec deletes remote main)", () => {
|
|
562
|
+
expect(() => rejectSpecialRef(":refs/heads/main", "branch")).toThrow(
|
|
563
|
+
/refspec\/revision syntax/,
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("rejects leading '+' (force-push refspec prefix)", () => {
|
|
568
|
+
expect(() => rejectSpecialRef("+main", "branch")).toThrow(/refspec\/revision syntax/);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("rejects '~' and '^' (revision modifiers that resolve to parents)", () => {
|
|
572
|
+
expect(() => rejectSpecialRef("main~1", "branch")).toThrow(/refspec\/revision syntax/);
|
|
573
|
+
expect(() => rejectSpecialRef("main^", "branch")).toThrow(/refspec\/revision syntax/);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("rejects whitespace (not permitted in git branch names)", () => {
|
|
577
|
+
expect(() => rejectSpecialRef("main other", "branch")).toThrow(/refspec\/revision syntax/);
|
|
578
|
+
expect(() => rejectSpecialRef("foo\tbar", "branch")).toThrow(/refspec\/revision syntax/);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("rejects shell/glob metacharacters forbidden in branch names", () => {
|
|
582
|
+
for (const b of ["main?", "main*", "main[", "main\\x"]) {
|
|
583
|
+
expect(() => rejectSpecialRef(b, "branch")).toThrow(/refspec\/revision syntax/);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("git tool security - validateTagName (push_tags refspec injection)", () => {
|
|
589
|
+
it("rejects tags containing ':' (refspec src:dst split)", () => {
|
|
590
|
+
// without this, "foo:refs/heads/main" would push the local refs/tags/foo's
|
|
591
|
+
// commit to remote main and bypass the push_branch default-branch guard.
|
|
592
|
+
expect(() => validateTagName("foo:refs/heads/main")).toThrow(/could be parsed as a refspec/);
|
|
593
|
+
expect(() => validateTagName("v1.0:bar")).toThrow(/refspec/);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("rejects tags with leading '-' (flag injection)", () => {
|
|
597
|
+
expect(() => validateTagName("-c")).toThrow(/starts with '-'/);
|
|
598
|
+
expect(() => validateTagName("--upload-pack=evil")).toThrow(/starts with '-'/);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("rejects tags with whitespace or control chars", () => {
|
|
602
|
+
expect(() => validateTagName("foo bar")).toThrow(/could be parsed/);
|
|
603
|
+
expect(() => validateTagName("foo\nrefs/heads/main")).toThrow(/could be parsed/);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("rejects tags with shell / refspec metacharacters", () => {
|
|
607
|
+
const bad = ["foo~1", "foo^", "foo?", "foo*", "foo[", "foo\\bar", "foo;evil"];
|
|
608
|
+
for (const t of bad) {
|
|
609
|
+
expect(() => validateTagName(t)).toThrow(/could be parsed/);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("allows plausible tag names", () => {
|
|
614
|
+
const ok = ["v1.0.0", "release-2024-01", "feature/thing", "v1", "hotfix_1"];
|
|
615
|
+
for (const t of ok) {
|
|
616
|
+
expect(() => validateTagName(t)).not.toThrow();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("rejects empty tag", () => {
|
|
621
|
+
expect(() => validateTagName("")).toThrow(/could be parsed/);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe("DeleteBranchTool - default-branch guard", () => {
|
|
626
|
+
// push: enabled authorizes pushes — not wholesale removal of the repo's
|
|
627
|
+
// primary branch. GitHub branch protection usually blocks this at the
|
|
628
|
+
// remote, but not every repo has protection on, so guard locally too.
|
|
629
|
+
function makeCtx(defaultBranch: string): ToolContext {
|
|
630
|
+
return {
|
|
631
|
+
payload: { push: "enabled" },
|
|
632
|
+
repo: { data: { default_branch: defaultBranch } },
|
|
633
|
+
gitToken: "test-token",
|
|
634
|
+
} as unknown as ToolContext;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
it("blocks deletion of the default branch even with push: enabled", async () => {
|
|
638
|
+
const tool = DeleteBranchTool(makeCtx("main"));
|
|
639
|
+
const result = (await (tool.execute as (p: unknown, ctx: unknown) => Promise<unknown>)(
|
|
640
|
+
{ branchName: "main" },
|
|
641
|
+
{} as Parameters<NonNullable<typeof tool.execute>>[1],
|
|
642
|
+
)) as { content: [{ text: string }]; isError?: boolean };
|
|
643
|
+
/* cast: FastMCP execute returns a union of content shapes; these tests
|
|
644
|
+
always return the handleToolError envelope, which matches this shape. */
|
|
645
|
+
expect(result.isError).toBe(true);
|
|
646
|
+
expect(result.content[0].text).toMatch(/default branch/i);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("honors the repo's actual default branch name (not just 'main')", async () => {
|
|
650
|
+
const tool = DeleteBranchTool(makeCtx("trunk"));
|
|
651
|
+
const result = (await (tool.execute as (p: unknown, ctx: unknown) => Promise<unknown>)(
|
|
652
|
+
{ branchName: "trunk" },
|
|
653
|
+
{} as Parameters<NonNullable<typeof tool.execute>>[1],
|
|
654
|
+
)) as { content: [{ text: string }]; isError?: boolean };
|
|
655
|
+
/* cast: FastMCP execute returns a union of content shapes; these tests
|
|
656
|
+
always return the handleToolError envelope, which matches this shape. */
|
|
657
|
+
expect(result.isError).toBe(true);
|
|
658
|
+
expect(result.content[0].text).toMatch(/default branch 'trunk'/);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("still blocks when the agent tries the refs/heads/... bypass", async () => {
|
|
662
|
+
// rejectSpecialRef catches this before the default-branch check, but the
|
|
663
|
+
// test asserts the chain stops it — either error is acceptable, just not
|
|
664
|
+
// a successful delete.
|
|
665
|
+
const tool = DeleteBranchTool(makeCtx("main"));
|
|
666
|
+
const result = (await (tool.execute as (p: unknown, ctx: unknown) => Promise<unknown>)(
|
|
667
|
+
{ branchName: "refs/heads/main" },
|
|
668
|
+
{} as Parameters<NonNullable<typeof tool.execute>>[1],
|
|
669
|
+
)) as { content: [{ text: string }]; isError?: boolean };
|
|
670
|
+
/* cast: FastMCP execute returns a union of content shapes; these tests
|
|
671
|
+
always return the handleToolError envelope, which matches this shape. */
|
|
672
|
+
expect(result.isError).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
describe("git tool security - checkoutPrBranch rejects malicious PR refs", () => {
|
|
677
|
+
// PR head/base ref names are attacker-controlled on forks (PR author picks
|
|
678
|
+
// headRef freely, and baseRef could be a maliciously-named branch on the
|
|
679
|
+
// target repo). they flow into `git fetch origin <ref>` and similar, so a
|
|
680
|
+
// ref starting with '-' would be parsed as a flag, not a refspec.
|
|
681
|
+
// checkoutPrBranch validates them up-front with rejectIfLeadingDash.
|
|
682
|
+
const basePr: PrData = {
|
|
683
|
+
number: 1,
|
|
684
|
+
headSha: "a".repeat(40),
|
|
685
|
+
headRef: "feature",
|
|
686
|
+
headRepoFullName: "user/repo",
|
|
687
|
+
baseRef: "main",
|
|
688
|
+
baseRepoFullName: "user/repo",
|
|
689
|
+
maintainerCanModify: false,
|
|
690
|
+
};
|
|
691
|
+
// checkoutPrBranch validates before any async call, so the params never get
|
|
692
|
+
// dereferenced — a cast is enough to satisfy the type checker.
|
|
693
|
+
const dummyParams = {} as Parameters<typeof checkoutPrBranch>[1];
|
|
694
|
+
|
|
695
|
+
it("rejects a leading-dash headRef before any git call", async () => {
|
|
696
|
+
await expect(
|
|
697
|
+
checkoutPrBranch({ ...basePr, headRef: "-upload-pack=evil" }, dummyParams),
|
|
698
|
+
).rejects.toThrow(/PR head ref.*starts with '-'/);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("rejects a leading-dash baseRef before any git call", async () => {
|
|
702
|
+
await expect(
|
|
703
|
+
checkoutPrBranch({ ...basePr, baseRef: "--config-env=FOO=BAR" }, dummyParams),
|
|
704
|
+
).rejects.toThrow(/PR base ref.*starts with '-'/);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
describe("dependency install - ignore-scripts logic", () => {
|
|
709
|
+
it("ignoreScripts is true when shell is disabled", () => {
|
|
710
|
+
expect(shouldIgnoreScripts("disabled")).toBe(true);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("ignoreScripts is false when shell is restricted (scripts run in stripped env)", () => {
|
|
714
|
+
expect(shouldIgnoreScripts("restricted")).toBe(false);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("ignoreScripts is false when shell is enabled", () => {
|
|
718
|
+
expect(shouldIgnoreScripts("enabled")).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
});
|