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:
Colin McDonnell
2026-02-23 23:34:29 +00:00
committed by pullfrog[bot]
parent b8a0d799ee
commit a7bd746f21
53 changed files with 672 additions and 672 deletions
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);