Restructure dash (#372)
* Restructure dash * WIP * WIP * refactor trigger UI: extract PR summary card, add mentions section, rename labels Co-authored-by: Cursor <cursoragent@cursor.com> * clean up console UI: remove info icons from section descriptions, rename mentions trigger Co-authored-by: Cursor <cursoragent@cursor.com> * fix review feedback: layout, terminology, form scope - extract console sidebar sections to module-level constant - align three-column layout breakpoints to xl (match sidebar visibility) - fix mixed shell/bash terminology in beta page - scope FormProvider to trigger sections only, restore autoComplete="off" Co-authored-by: Cursor <cursoragent@cursor.com> * Bump --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
b8a0d799ee
commit
a7bd746f21
+5
-5
@@ -183,7 +183,7 @@ export async function checkoutPrBranch(
|
||||
pullNumber: number,
|
||||
params: CheckoutPrBranchParams
|
||||
): Promise<CheckoutPrBranchResult> {
|
||||
const { octokit, owner, name, gitToken, toolState, bash } = params;
|
||||
const { octokit, owner, name, gitToken, toolState, shell } = params;
|
||||
log.info(`» checking out PR #${pullNumber}...`);
|
||||
|
||||
// fetch PR metadata
|
||||
@@ -218,7 +218,7 @@ export async function checkoutPrBranch(
|
||||
log.debug(`» fetching base branch (${baseBranch})...`);
|
||||
$git("fetch", ["--no-tags", "origin", baseBranch], {
|
||||
token: gitToken,
|
||||
restricted: bash !== "enabled",
|
||||
restricted: shell !== "enabled",
|
||||
});
|
||||
|
||||
// checkout base branch first to avoid "refusing to fetch into current branch" error
|
||||
@@ -229,7 +229,7 @@ export async function checkoutPrBranch(
|
||||
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
|
||||
$git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], {
|
||||
token: gitToken,
|
||||
restricted: bash !== "enabled",
|
||||
restricted: shell !== "enabled",
|
||||
});
|
||||
|
||||
// checkout the branch
|
||||
@@ -243,7 +243,7 @@ export async function checkoutPrBranch(
|
||||
log.debug(`» fetching base branch (${baseBranch})...`);
|
||||
$git("fetch", ["--no-tags", "origin", baseBranch], {
|
||||
token: gitToken,
|
||||
restricted: bash !== "enabled",
|
||||
restricted: shell !== "enabled",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
name: ctx.repo.name,
|
||||
gitToken: ctx.gitToken,
|
||||
toolState: ctx.toolState,
|
||||
bash: ctx.payload.bash,
|
||||
shell: ctx.payload.shell,
|
||||
postCheckoutScript: ctx.postCheckoutScript,
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -14,7 +14,7 @@ function formatPrepResults(results: PrepResult[]): string {
|
||||
if (results.length === 0) {
|
||||
return `No supported language detected in this repository (checked for package.json, requirements.txt, pyproject.toml, etc.).
|
||||
|
||||
Inspect the repository structure to determine how dependencies should be installed, then use bash to install them.`;
|
||||
Inspect the repository structure to determine how dependencies should be installed, then use shell to install them.`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -45,14 +45,14 @@ Inspect the repository structure to determine how dependencies should be install
|
||||
Error:
|
||||
${errorMsg}
|
||||
|
||||
Use bash or other tools at your disposal to diagnose and resolve the issue, then install dependencies manually.`);
|
||||
Use shell or other tools at your disposal to diagnose and resolve the issue, then install dependencies manually.`);
|
||||
} else if (result.language === "python") {
|
||||
lines.push(`${langDisplay} dependency installation failed via ${result.packageManager} (from ${result.configFile}).
|
||||
|
||||
Error:
|
||||
${errorMsg}
|
||||
|
||||
Use bash or other tools at your disposal to diagnose and resolve the issue, then install dependencies manually.`);
|
||||
Use shell or other tools at your disposal to diagnose and resolve the issue, then install dependencies manually.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ Use bash or other tools at your disposal to diagnose and resolve the issue, then
|
||||
if (lines.length === 0) {
|
||||
return `No supported language detected in this repository (checked for package.json, requirements.txt, pyproject.toml, etc.).
|
||||
|
||||
Inspect the repository structure to determine how dependencies should be installed, then use bash to install them.`;
|
||||
Inspect the repository structure to determine how dependencies should be installed, then use shell to install them.`;
|
||||
}
|
||||
|
||||
return lines.join("\n\n");
|
||||
@@ -75,10 +75,10 @@ function startInstallation(ctx: ToolContext): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: when bash is disabled, suppress lifecycle scripts to prevent
|
||||
// SECURITY: when shell is disabled, suppress lifecycle scripts to prevent
|
||||
// agents from using package.json scripts as a backdoor for code execution
|
||||
const prepOptions: PrepOptions = {
|
||||
ignoreScripts: ctx.payload.bash === "disabled",
|
||||
ignoreScripts: ctx.payload.shell === "disabled",
|
||||
};
|
||||
|
||||
// initialize state and start installation
|
||||
|
||||
+16
-16
@@ -9,7 +9,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { type } from "arktype";
|
||||
import type { BashPermission } from "../external.ts";
|
||||
import type { ShellPermission } from "../external.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ListDirectoryParams = type({
|
||||
// SECURITY: files that git interprets and can trigger code execution.
|
||||
// .gitattributes can define filter drivers (clean/smudge) that execute arbitrary commands.
|
||||
// .gitmodules can reference malicious submodule URLs that execute code on update.
|
||||
// only blocked when bash is disabled — in restricted mode the agent already has bash
|
||||
// only blocked when shell is disabled — in restricted mode the agent already has shell
|
||||
// and could write these files via shell, so blocking via MCP is redundant.
|
||||
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
|
||||
|
||||
@@ -80,18 +80,18 @@ function resolveReadPath(filePath: string): string {
|
||||
}
|
||||
|
||||
// resolve and validate a write path. enforces:
|
||||
// - repo-scoping with symlink protection (when bash !== "enabled")
|
||||
// - repo-scoping with symlink protection (when shell !== "enabled")
|
||||
// - .git/ always blocked (defense-in-depth)
|
||||
// - .gitattributes/.gitmodules blocked when bash === "disabled"
|
||||
// - .gitattributes/.gitmodules blocked when shell === "disabled"
|
||||
//
|
||||
// when bash=enabled, repo-scoping is dropped — the agent can write anywhere via native
|
||||
// bash, so restricting file_write to the repo would be security theater.
|
||||
function resolveWritePath(filePath: string, bashPermission: BashPermission): string {
|
||||
// when shell=enabled, repo-scoping is dropped — the agent can write anywhere via native
|
||||
// shell, so restricting file_write to the repo would be security theater.
|
||||
function resolveWritePath(filePath: string, shellPermission: ShellPermission): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// repo-scoping: enforced when agent doesn't have full bash
|
||||
if (bashPermission !== "enabled") {
|
||||
// repo-scoping: enforced when agent doesn't have full shell
|
||||
if (shellPermission !== "enabled") {
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
@@ -121,17 +121,17 @@ function resolveWritePath(filePath: string, bashPermission: BashPermission): str
|
||||
}
|
||||
}
|
||||
|
||||
// .git always blocked anywhere in the path (defense-in-depth even with bash=enabled)
|
||||
// .git always blocked anywhere in the path (defense-in-depth even with shell=enabled)
|
||||
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
|
||||
throw new Error(`writing to .git is not allowed: ${filePath}`);
|
||||
}
|
||||
|
||||
// git-interpreted files blocked anywhere in the path when bash is disabled
|
||||
if (bashPermission === "disabled") {
|
||||
// git-interpreted files blocked anywhere in the path when shell is disabled
|
||||
if (shellPermission === "disabled") {
|
||||
const basename = resolved.split("/").pop() || "";
|
||||
if (GIT_INTERPRETED_FILES.includes(basename)) {
|
||||
throw new Error(
|
||||
`writing to ${basename} is not allowed when bash is ${bashPermission} (can trigger code execution via git filter drivers): ${filePath}`
|
||||
`writing to ${basename} is not allowed when shell is ${shellPermission} (can trigger code execution via git filter drivers): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export function FileWriteTool(ctx: ToolContext) {
|
||||
"Writes to .git/ are blocked. Creates parent directories if needed.",
|
||||
parameters: FileWriteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.shell);
|
||||
const dir = dirname(resolved);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolved, params.content, "utf-8");
|
||||
@@ -201,7 +201,7 @@ export function FileEditTool(ctx: ToolContext) {
|
||||
throw new Error("old_string and new_string are identical");
|
||||
}
|
||||
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.shell);
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
const count = content.split(params.old_string).length - 1;
|
||||
|
||||
@@ -232,7 +232,7 @@ export function FileDeleteTool(ctx: ToolContext) {
|
||||
"Deletes to .git/ are blocked. Cannot delete directories.",
|
||||
parameters: FileDeleteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.shell);
|
||||
unlinkSync(resolved);
|
||||
return { path: params.path, deleted: true };
|
||||
}),
|
||||
|
||||
+17
-17
@@ -140,7 +140,7 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
}
|
||||
$git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
restricted: ctx.payload.bash !== "enabled",
|
||||
restricted: ctx.payload.shell !== "enabled",
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -163,11 +163,11 @@ const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
||||
clone: "Repository already cloned. Use checkout_pr for PR branches.",
|
||||
};
|
||||
|
||||
// SECURITY: subcommands blocked when bash is disabled.
|
||||
// in disabled mode the agent has NO shell access, so these subcommands are the
|
||||
// SECURITY: subcommands blocked when shell is disabled.
|
||||
// in disabled mode the agent has no shell access, so these subcommands are the
|
||||
// primary escape vectors for arbitrary code execution. in restricted mode the
|
||||
// agent already has bash in a stripped sandbox, so blocking these is redundant.
|
||||
const NOBASH_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
// agent already has shell in a stripped sandbox, so blocking these is redundant.
|
||||
const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
|
||||
submodule:
|
||||
"Blocked: git submodule can reference malicious repositories and execute code on update.",
|
||||
@@ -181,7 +181,7 @@ const NOBASH_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
};
|
||||
|
||||
// SECURITY: subcommand-specific arg flags that execute code.
|
||||
// only blocked when bash is disabled — in restricted mode the agent already
|
||||
// only blocked when shell is disabled — in restricted mode the agent already
|
||||
// has shell access in a stripped sandbox, so these provide no additional security.
|
||||
//
|
||||
// NOTE: global git flags like -c and --config-env are NOT included here
|
||||
@@ -192,14 +192,14 @@ const NOBASH_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
//
|
||||
// matched as: arg === flag OR arg starts with flag + "="
|
||||
// (avoids false positives like --exclude matching --exec)
|
||||
const NOBASH_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
||||
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
||||
|
||||
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
|
||||
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
|
||||
//
|
||||
// critical attack: git -c "alias.x=!evil-command" x
|
||||
// -> sets alias "x" to a shell command via -c config injection, then runs it
|
||||
// -> achieves arbitrary code execution even with bash=disabled
|
||||
// -> achieves arbitrary code execution even with shell=disabled
|
||||
const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
|
||||
|
||||
const Git = type({
|
||||
@@ -222,18 +222,18 @@ export function GitTool(ctx: ToolContext) {
|
||||
throw new Error(`git ${subcommand} requires authentication. ${redirect}`);
|
||||
}
|
||||
|
||||
// SECURITY: block dangerous subcommands when bash is disabled.
|
||||
// in restricted mode the agent has bash in a stripped sandbox, so blocking
|
||||
// these through the MCP tool is redundant (agent can do it via bash).
|
||||
if (ctx.payload.bash === "disabled") {
|
||||
const blocked = NOBASH_BLOCKED_SUBCOMMANDS[subcommand];
|
||||
// SECURITY: block dangerous subcommands when shell is disabled.
|
||||
// in restricted mode the agent has shell in a stripped sandbox, so blocking
|
||||
// these through the MCP tool is redundant (agent can do it via shell).
|
||||
if (ctx.payload.shell === "disabled") {
|
||||
const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[subcommand];
|
||||
if (blocked) {
|
||||
throw new Error(blocked);
|
||||
}
|
||||
|
||||
// block subcommand-specific flags that execute arbitrary code
|
||||
for (const arg of args) {
|
||||
const isBlocked = NOBASH_BLOCKED_ARGS.some(
|
||||
const isBlocked = NOSHELL_BLOCKED_ARGS.some(
|
||||
(flag) => arg === flag || arg.startsWith(flag + "=")
|
||||
);
|
||||
if (isBlocked) {
|
||||
@@ -267,7 +267,7 @@ export function GitFetchTool(ctx: ToolContext) {
|
||||
}
|
||||
$git("fetch", fetchArgs, {
|
||||
token: ctx.gitToken,
|
||||
restricted: ctx.payload.bash !== "enabled",
|
||||
restricted: ctx.payload.shell !== "enabled",
|
||||
});
|
||||
return { success: true, ref: params.ref };
|
||||
}),
|
||||
@@ -295,7 +295,7 @@ export function DeleteBranchTool(ctx: ToolContext) {
|
||||
|
||||
$git("push", ["origin", "--delete", params.branchName], {
|
||||
token: ctx.gitToken,
|
||||
restricted: ctx.payload.bash !== "enabled",
|
||||
restricted: ctx.payload.shell !== "enabled",
|
||||
});
|
||||
return { success: true, deleted: params.branchName };
|
||||
}),
|
||||
@@ -325,7 +325,7 @@ export function PushTagsTool(ctx: ToolContext) {
|
||||
const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`];
|
||||
$git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
restricted: ctx.payload.bash !== "enabled",
|
||||
restricted: ctx.payload.shell !== "enabled",
|
||||
});
|
||||
return { success: true, tag: params.tag };
|
||||
}),
|
||||
|
||||
+59
-59
@@ -10,9 +10,9 @@ const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
||||
clone: "Repository already cloned. Use checkout_pr for PR branches.",
|
||||
};
|
||||
|
||||
// only blocked when bash is disabled — in restricted mode the agent has bash
|
||||
// only blocked when shell is disabled — in restricted mode the agent has shell
|
||||
// in a stripped sandbox so blocking these is redundant
|
||||
const NOBASH_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
|
||||
submodule:
|
||||
"Blocked: git submodule can reference malicious repositories and execute code on update.",
|
||||
@@ -24,14 +24,14 @@ const NOBASH_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
bisect: "Blocked: git bisect run can execute arbitrary shell commands.",
|
||||
};
|
||||
|
||||
const NOBASH_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
||||
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
||||
|
||||
type BashPermission = "disabled" | "restricted" | "enabled";
|
||||
type ShellPermission = "disabled" | "restricted" | "enabled";
|
||||
|
||||
type ValidateGitParams = {
|
||||
subcommand: string;
|
||||
args: string[];
|
||||
bashPermission: BashPermission;
|
||||
shellPermission: ShellPermission;
|
||||
};
|
||||
|
||||
// matches the arkregex pattern used in the Git schema
|
||||
@@ -49,15 +49,15 @@ function validateGitCommand(params: ValidateGitParams): string | null {
|
||||
return `git ${params.subcommand} requires authentication. ${redirect}`;
|
||||
}
|
||||
|
||||
// subcommand and arg blocking only applies when bash is disabled
|
||||
if (params.bashPermission === "disabled") {
|
||||
const blocked = NOBASH_BLOCKED_SUBCOMMANDS[params.subcommand];
|
||||
// subcommand and arg blocking only applies when shell is disabled
|
||||
if (params.shellPermission === "disabled") {
|
||||
const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[params.subcommand];
|
||||
if (blocked) {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
for (const arg of params.args) {
|
||||
const isBlocked = NOBASH_BLOCKED_ARGS.some(
|
||||
const isBlocked = NOSHELL_BLOCKED_ARGS.some(
|
||||
(flag) => arg === flag || arg.startsWith(flag + "=")
|
||||
);
|
||||
if (isBlocked) {
|
||||
@@ -71,12 +71,12 @@ function validateGitCommand(params: ValidateGitParams): string | null {
|
||||
|
||||
describe("git tool security - subcommand regex validation", () => {
|
||||
it("blocks -c flag as subcommand in ALL modes (alias injection)", () => {
|
||||
const modes: BashPermission[] = ["disabled", "restricted", "enabled"];
|
||||
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
|
||||
for (const mode of modes) {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "-c",
|
||||
args: ["alias.x=!evil-command", "x"],
|
||||
bashPermission: mode,
|
||||
shellPermission: mode,
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "--exec-path=/malicious",
|
||||
args: ["status"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "-C",
|
||||
args: ["/tmp", "init"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
});
|
||||
@@ -104,7 +104,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "--config-env",
|
||||
args: ["core.pager=PATH", "log"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: flag,
|
||||
args: [],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
}
|
||||
@@ -125,7 +125,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "STATUS",
|
||||
args: [],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
});
|
||||
@@ -136,7 +136,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: sub,
|
||||
args: [],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("Git subcommand");
|
||||
}
|
||||
@@ -148,7 +148,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: sub,
|
||||
args: [],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
}
|
||||
@@ -160,7 +160,7 @@ describe("git tool security - subcommand regex validation", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: sub,
|
||||
args: [],
|
||||
bashPermission: "enabled",
|
||||
shellPermission: "enabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
}
|
||||
@@ -172,16 +172,16 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "config",
|
||||
args: ["core.hooksPath", "./hooks"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("git config");
|
||||
});
|
||||
|
||||
it("allows config in restricted mode (agent has bash)", () => {
|
||||
it("allows config in restricted mode (agent has shell)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "config",
|
||||
args: ["filter.evil.clean", "bash -c 'evil'"],
|
||||
bashPermission: "restricted",
|
||||
shellPermission: "restricted",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "submodule",
|
||||
args: ["add", "https://evil.com/repo.git"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("submodule");
|
||||
});
|
||||
@@ -199,7 +199,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "submodule",
|
||||
args: ["add", "https://example.com/repo.git"],
|
||||
bashPermission: "restricted",
|
||||
shellPermission: "restricted",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -208,7 +208,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "rebase",
|
||||
args: ["--exec", "evil-command", "HEAD~1"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("rebase");
|
||||
});
|
||||
@@ -217,7 +217,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "rebase",
|
||||
args: ["main"],
|
||||
bashPermission: "restricted",
|
||||
shellPermission: "restricted",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -226,7 +226,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "bisect",
|
||||
args: ["run", "evil-command"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("bisect");
|
||||
});
|
||||
@@ -235,7 +235,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "filter-branch",
|
||||
args: ["--tree-filter", "evil-command", "HEAD"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("filter-branch");
|
||||
});
|
||||
@@ -246,7 +246,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: sub,
|
||||
args: [],
|
||||
bashPermission: "enabled",
|
||||
shellPermission: "enabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
}
|
||||
@@ -258,7 +258,7 @@ describe("git tool security - blocked subcommands (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: sub,
|
||||
args: [],
|
||||
bashPermission: "restricted",
|
||||
shellPermission: "restricted",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
}
|
||||
@@ -270,7 +270,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "log",
|
||||
args: ["--exec", "evil-command"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("arbitrary code");
|
||||
});
|
||||
@@ -279,7 +279,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "log",
|
||||
args: ["--exec=evil-command"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("arbitrary code");
|
||||
});
|
||||
@@ -288,7 +288,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "difftool",
|
||||
args: ["--extcmd=evil-command", "HEAD~1"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("arbitrary code");
|
||||
});
|
||||
@@ -297,16 +297,16 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "ls-remote",
|
||||
args: ["--upload-pack=evil"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toContain("arbitrary code");
|
||||
});
|
||||
|
||||
it("allows --exec in restricted mode (agent has bash)", () => {
|
||||
it("allows --exec in restricted mode (agent has shell)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "rebase",
|
||||
args: ["--exec", "npm test", "HEAD~1"],
|
||||
bashPermission: "restricted",
|
||||
shellPermission: "restricted",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -315,7 +315,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "difftool",
|
||||
args: ["--extcmd=less"],
|
||||
bashPermission: "restricted",
|
||||
shellPermission: "restricted",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "difftool",
|
||||
args: ["--extcmd=less"],
|
||||
bashPermission: "enabled",
|
||||
shellPermission: "enabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -333,7 +333,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "log",
|
||||
args: ["--oneline", "-10", "--format=%H %s"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -342,7 +342,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "ls-files",
|
||||
args: ["--exclude-standard"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -351,7 +351,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "log",
|
||||
args: ["--execute-something"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -360,7 +360,7 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "log",
|
||||
args: ["-c", "--oneline"],
|
||||
bashPermission: "disabled",
|
||||
shellPermission: "disabled",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
@@ -368,12 +368,12 @@ describe("git tool security - blocked arg flags (disabled mode only)", () => {
|
||||
|
||||
describe("git tool security - auth redirect", () => {
|
||||
it("redirects push in all modes", () => {
|
||||
const modes: BashPermission[] = ["disabled", "restricted", "enabled"];
|
||||
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
|
||||
for (const mode of modes) {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "push",
|
||||
args: [],
|
||||
bashPermission: mode,
|
||||
shellPermission: mode,
|
||||
});
|
||||
expect(error).toContain("authentication");
|
||||
}
|
||||
@@ -383,7 +383,7 @@ describe("git tool security - auth redirect", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "fetch",
|
||||
args: [],
|
||||
bashPermission: "enabled",
|
||||
shellPermission: "enabled",
|
||||
});
|
||||
expect(error).toContain("authentication");
|
||||
});
|
||||
@@ -392,7 +392,7 @@ describe("git tool security - auth redirect", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "pull",
|
||||
args: [],
|
||||
bashPermission: "enabled",
|
||||
shellPermission: "enabled",
|
||||
});
|
||||
expect(error).toContain("authentication");
|
||||
});
|
||||
@@ -401,7 +401,7 @@ describe("git tool security - auth redirect", () => {
|
||||
const error = validateGitCommand({
|
||||
subcommand: "clone",
|
||||
args: [],
|
||||
bashPermission: "enabled",
|
||||
shellPermission: "enabled",
|
||||
});
|
||||
expect(error).toContain("authentication");
|
||||
});
|
||||
@@ -420,19 +420,19 @@ type ValidateWritePathResult = {
|
||||
// without requiring real filesystem operations (for unit testing)
|
||||
function validateWritePathSecurity(
|
||||
relative: string,
|
||||
bashPermission: BashPermission
|
||||
shellPermission: ShellPermission
|
||||
): ValidateWritePathResult {
|
||||
if (relative === ".git" || relative.startsWith(".git/")) {
|
||||
return { allowed: false, error: `writing to .git is not allowed: ${relative}` };
|
||||
}
|
||||
|
||||
// only blocked when bash is disabled
|
||||
if (bashPermission === "disabled") {
|
||||
// only blocked when shell is disabled
|
||||
if (shellPermission === "disabled") {
|
||||
const basename = relative.split("/").pop() || "";
|
||||
if (GIT_INTERPRETED_FILES.includes(basename)) {
|
||||
return {
|
||||
allowed: false,
|
||||
error: `writing to ${basename} is not allowed when bash is ${bashPermission}`,
|
||||
error: `writing to ${basename} is not allowed when shell is ${shellPermission}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -442,7 +442,7 @@ function validateWritePathSecurity(
|
||||
|
||||
describe("file tool security - .git protection", () => {
|
||||
it("blocks .git directory in all modes", () => {
|
||||
const modes: BashPermission[] = ["disabled", "restricted", "enabled"];
|
||||
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
|
||||
for (const mode of modes) {
|
||||
const result = validateWritePathSecurity(".git", mode);
|
||||
expect(result.allowed).toBe(false);
|
||||
@@ -472,7 +472,7 @@ describe("file tool security - git-interpreted files (disabled mode only)", () =
|
||||
expect(result.error).toContain(".gitattributes");
|
||||
});
|
||||
|
||||
it("allows .gitattributes in restricted mode (agent has bash)", () => {
|
||||
it("allows .gitattributes in restricted mode (agent has shell)", () => {
|
||||
const result = validateWritePathSecurity(".gitattributes", "restricted");
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
@@ -514,7 +514,7 @@ describe("file tool security - git-interpreted files (disabled mode only)", () =
|
||||
|
||||
it("allows normal files in all modes", () => {
|
||||
const files = ["README.md", "src/index.ts", "package.json", ".env", ".gitignore"];
|
||||
const modes: BashPermission[] = ["disabled", "restricted", "enabled"];
|
||||
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
|
||||
for (const file of files) {
|
||||
for (const mode of modes) {
|
||||
const result = validateWritePathSecurity(file, mode);
|
||||
@@ -537,20 +537,20 @@ describe("file tool security - git-interpreted files (disabled mode only)", () =
|
||||
// ─── dependency install security tests ──────────────────────────────────
|
||||
|
||||
// mirrors the logic in dependencies.ts startInstallation()
|
||||
function shouldIgnoreScripts(bashPermission: BashPermission): boolean {
|
||||
return bashPermission === "disabled";
|
||||
function shouldIgnoreScripts(shellPermission: ShellPermission): boolean {
|
||||
return shellPermission === "disabled";
|
||||
}
|
||||
|
||||
describe("dependency install - ignore-scripts logic", () => {
|
||||
it("ignoreScripts is true when bash is disabled", () => {
|
||||
it("ignoreScripts is true when shell is disabled", () => {
|
||||
expect(shouldIgnoreScripts("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignoreScripts is false when bash is restricted (scripts run in stripped env)", () => {
|
||||
it("ignoreScripts is false when shell is restricted (scripts run in stripped env)", () => {
|
||||
expect(shouldIgnoreScripts("restricted")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignoreScripts is false when bash is enabled", () => {
|
||||
it("ignoreScripts is false when shell is enabled", () => {
|
||||
expect(shouldIgnoreScripts("enabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-7
@@ -126,7 +126,6 @@ export const ORCHESTRATOR_ONLY_TOOLS = [
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { RunContextData } from "../utils/runContextData.ts";
|
||||
import { AskQuestionTool } from "./askQuestion.ts";
|
||||
import { BashTool, KillBackgroundTool } from "./bash.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
@@ -165,6 +164,7 @@ import {
|
||||
} from "./reviewComments.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
import { KillBackgroundTool, ShellTool } from "./shell.ts";
|
||||
import { UploadFileTool } from "./upload.ts";
|
||||
|
||||
const mcpPortStart = 3764;
|
||||
@@ -237,12 +237,12 @@ function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
ReportProgressTool(ctx),
|
||||
];
|
||||
|
||||
// only add BashTool when bash is "restricted"
|
||||
// - "enabled": native bash only (no MCP bash needed)
|
||||
// - "restricted": MCP bash only (native blocked, env filtered)
|
||||
// - "disabled": no bash at all
|
||||
if (ctx.payload.bash === "restricted") {
|
||||
tools.push(BashTool(ctx));
|
||||
// only add ShellTool when shell is "restricted"
|
||||
// - "enabled": native shell only (no MCP shell needed)
|
||||
// - "restricted": MCP shell only (native blocked, env filtered)
|
||||
// - "disabled": no shell at all
|
||||
if (ctx.payload.shell === "restricted") {
|
||||
tools.push(ShellTool(ctx));
|
||||
tools.push(KillBackgroundTool(ctx));
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
// changes to bash security (filterEnv, spawnBash) should be reflected in wiki/security.md and docs/security.mdx
|
||||
// changes to shell security (filterEnv, spawnShell) should be reflected in wiki/security.md and docs/security.mdx
|
||||
import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { closeSync, openSync, writeFileSync } from "node:fs";
|
||||
@@ -9,7 +9,7 @@ import { resolveEnv } from "../utils/secrets.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const BashParams = type({
|
||||
export const ShellParams = type({
|
||||
command: "string",
|
||||
description: "string",
|
||||
"timeout?": "number",
|
||||
@@ -82,7 +82,7 @@ function detectSandboxMethod(): SandboxMethod {
|
||||
return "none";
|
||||
}
|
||||
|
||||
function spawnBash(params: SpawnParams): ChildProcess {
|
||||
function spawnShell(params: SpawnParams): ChildProcess {
|
||||
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||
const sandboxMethod = detectSandboxMethod();
|
||||
|
||||
@@ -153,9 +153,9 @@ function getTempDir(): string {
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
export function BashTool(ctx: ToolContext) {
|
||||
export function ShellTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "bash",
|
||||
name: "shell",
|
||||
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
|
||||
|
||||
Use this tool to:
|
||||
@@ -163,11 +163,11 @@ Use this tool to:
|
||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||
- Run tests and linters
|
||||
- Perform git operations`,
|
||||
parameters: BashParams,
|
||||
parameters: ShellParams,
|
||||
execute: execute(async (params) => {
|
||||
const timeout = Math.min(params.timeout ?? 30000, 120000);
|
||||
const cwd = params.working_directory ?? process.cwd();
|
||||
const env = resolveEnv(ctx.payload.bash === "enabled" ? "inherit" : "restricted");
|
||||
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
|
||||
|
||||
if (params.background) {
|
||||
const tempDir = getTempDir();
|
||||
@@ -177,7 +177,7 @@ Use this tool to:
|
||||
const logFd = openSync(outputPath, "a");
|
||||
let proc: ChildProcess;
|
||||
try {
|
||||
proc = spawnBash({
|
||||
proc = spawnShell({
|
||||
command: params.command,
|
||||
env,
|
||||
cwd,
|
||||
@@ -200,7 +200,7 @@ Use this tool to:
|
||||
};
|
||||
}
|
||||
|
||||
const proc = spawnBash({
|
||||
const proc = spawnShell({
|
||||
command: params.command,
|
||||
env,
|
||||
cwd,
|
||||
@@ -243,7 +243,7 @@ Use this tool to:
|
||||
|
||||
const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
|
||||
if (finalExitCode !== 0) {
|
||||
log.info(`bash command failed with exit code ${finalExitCode}: ${params.command}`);
|
||||
log.info(`shell command failed with exit code ${finalExitCode}: ${params.command}`);
|
||||
if (output) log.info(`output: ${output.trim()}`);
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ export const KillBackgroundParams = type({
|
||||
export function KillBackgroundTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "kill_background",
|
||||
description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with bash({ background: true }).`,
|
||||
description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with shell({ background: true }).`,
|
||||
parameters: KillBackgroundParams,
|
||||
execute: execute(async (params) => {
|
||||
const proc = ctx.toolState.backgroundProcesses.get(params.handle);
|
||||
Reference in New Issue
Block a user