harden sandbox escape vectors for bash disabled/restricted modes (#257)

* harden sandbox escape vectors for bash disabled/restricted modes

block git config injection (-c flag as subcommand), dangerous subcommands
(config, submodule, rebase, bisect), code-executing arg flags (--exec,
--extcmd), .gitattributes/.gitmodules writes, and package lifecycle scripts.
add retry logic to test runner for transient failures. add security unit
tests and adhoc attack tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* only filter subcommands in nobash, remove nobash from ui

* use regex matching

* iterate on tests

* simplify githooks

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
David Blass
2026-02-11 02:02:41 +00:00
committed by pullfrog[bot]
parent f37d02b292
commit bc28c658f2
32 changed files with 2515 additions and 1048 deletions
+43 -9
View File
@@ -91,13 +91,38 @@ function resolveAndValidatePath(filePath: string): string {
return resolved;
}
function validateWritePath(filePath: string): string {
const resolved = resolveAndValidatePath(filePath);
// 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
// and could write these files via shell, so blocking via MCP is redundant.
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
type BashPermission = "disabled" | "restricted" | "enabled";
type ValidateWritePathParams = {
filePath: string;
bashPermission: BashPermission;
};
function validateWritePath(params: ValidateWritePathParams): string {
const resolved = resolveAndValidatePath(params.filePath);
const cwd = realpathSync(process.cwd());
const relative = resolved.slice(cwd.length + 1);
if (relative === ".git" || relative.startsWith(".git/")) {
throw new Error(`writing to .git is not allowed: ${filePath}`);
throw new Error(`writing to .git is not allowed: ${params.filePath}`);
}
// block git-interpreted files only when bash is disabled (no shell = no other way to create them)
if (params.bashPermission === "disabled") {
const basename = relative.split("/").pop() || "";
if (GIT_INTERPRETED_FILES.includes(basename)) {
throw new Error(
`writing to ${basename} is not allowed when bash is ${params.bashPermission} (can trigger code execution via git filter drivers): ${params.filePath}`
);
}
}
return resolved;
}
@@ -128,13 +153,16 @@ export function FileReadTool(_ctx: ToolContext) {
});
}
export function FileWriteTool(_ctx: ToolContext) {
export function FileWriteTool(ctx: ToolContext) {
return tool({
name: "file_write",
description: `Write content to a file in the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Writes to .git/ are blocked. Creates parent directories if needed.`,
parameters: FileWriteParams,
execute: execute(async (params) => {
const resolved = validateWritePath(params.path);
const resolved = validateWritePath({
filePath: params.path,
bashPermission: ctx.payload.bash,
});
const dir = dirname(resolved);
mkdirSync(dir, { recursive: true });
writeFileSync(resolved, params.content, "utf-8");
@@ -143,7 +171,7 @@ export function FileWriteTool(_ctx: ToolContext) {
});
}
export function FileEditTool(_ctx: ToolContext) {
export function FileEditTool(ctx: ToolContext) {
return tool({
name: "file_edit",
description: `Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence — set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.`,
@@ -156,7 +184,10 @@ export function FileEditTool(_ctx: ToolContext) {
throw new Error("old_string and new_string are identical");
}
const resolved = validateWritePath(params.path);
const resolved = validateWritePath({
filePath: params.path,
bashPermission: ctx.payload.bash,
});
const content = readFileSync(resolved, "utf-8");
const count = content.split(params.old_string).length - 1;
@@ -179,13 +210,16 @@ export function FileEditTool(_ctx: ToolContext) {
});
}
export function FileDeleteTool(_ctx: ToolContext) {
export function FileDeleteTool(ctx: ToolContext) {
return tool({
name: "file_delete",
description: `Delete a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Deletes to .git/ are blocked. Cannot delete directories.`,
parameters: FileDeleteParams,
execute: execute(async (params) => {
const resolved = validateWritePath(params.path);
const resolved = validateWritePath({
filePath: params.path,
bashPermission: ctx.payload.bash,
});
unlinkSync(resolved);
return { path: params.path, deleted: true };
}),