bc28c658f2
* 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>
247 lines
9.1 KiB
TypeScript
247 lines
9.1 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { type } from "arktype";
|
|
import type { ToolContext } from "./server.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
export const FileReadParams = type({
|
|
path: "string",
|
|
"offset?": "number",
|
|
"limit?": "number",
|
|
});
|
|
|
|
export const FileWriteParams = type({
|
|
path: "string",
|
|
content: "string",
|
|
});
|
|
|
|
export const FileEditParams = type({
|
|
path: "string",
|
|
old_string: "string",
|
|
new_string: "string",
|
|
"replace_all?": "boolean",
|
|
});
|
|
|
|
export const FileDeleteParams = type({
|
|
path: "string",
|
|
});
|
|
|
|
export const ListDirectoryParams = type({
|
|
path: "string",
|
|
});
|
|
|
|
// resolve path and validate it is within the repository.
|
|
// uses realpathSync to follow symlinks and prevent symlink-based escapes.
|
|
//
|
|
// threat model: the primary scenario where symlink protection matters is a
|
|
// malicious PR that plants symlinks in the repo (e.g. `secrets -> /etc/shadow`).
|
|
// git materializes symlinks on linux, so after checkout the working tree contains
|
|
// live symlinks. without realpathSync, file_read("secrets") would leak host files.
|
|
//
|
|
// when bash is enabled the agent can already `cat /etc/shadow` directly, so the
|
|
// symlink check is defense-in-depth for that case. the check is most meaningful
|
|
// when bash is disabled — the agent's only filesystem access is through these MCP
|
|
// tools, and pre-planted symlinks become the sole escape vector.
|
|
//
|
|
// the path traversal check (resolve + startsWith) is always useful regardless of
|
|
// bash — it blocks `../../etc/passwd` style escapes on every configuration.
|
|
function resolveAndValidatePath(filePath: string): string {
|
|
const cwd = realpathSync(process.cwd());
|
|
const resolved = resolve(cwd, filePath);
|
|
|
|
// if the target exists, resolve symlinks to get the real location
|
|
if (existsSync(resolved)) {
|
|
const real = realpathSync(resolved);
|
|
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
|
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
|
}
|
|
return real;
|
|
}
|
|
|
|
// target doesn't exist yet (common for writes) — walk up to find
|
|
// the first existing ancestor and verify it resolves within the repo.
|
|
// this prevents creating files through symlinked parent directories.
|
|
let ancestor = dirname(resolved);
|
|
while (!existsSync(ancestor)) {
|
|
const parent = dirname(ancestor);
|
|
if (parent === ancestor) break; // reached filesystem root
|
|
ancestor = parent;
|
|
}
|
|
|
|
if (existsSync(ancestor)) {
|
|
const realAncestor = realpathSync(ancestor);
|
|
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
|
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
|
}
|
|
}
|
|
|
|
// also verify the resolved path (without symlink resolution) is within cwd
|
|
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
|
throw new Error(`path must be within the repository: ${filePath}`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
// 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: ${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;
|
|
}
|
|
|
|
export function FileReadTool(_ctx: ToolContext) {
|
|
return tool({
|
|
name: "file_read",
|
|
description: `Read a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed.`,
|
|
parameters: FileReadParams,
|
|
execute: execute(async (params) => {
|
|
const resolved = resolveAndValidatePath(params.path);
|
|
const raw = readFileSync(resolved, "utf-8");
|
|
const lines = raw.split("\n");
|
|
|
|
const offset = params.offset;
|
|
const limit = params.limit;
|
|
|
|
if (offset === undefined && limit === undefined) {
|
|
return { content: raw };
|
|
}
|
|
|
|
// 1-indexed line numbers, clamp to valid range
|
|
const oneBasedOffset = offset ?? 1;
|
|
const start = Math.max(0, oneBasedOffset - 1);
|
|
const end = limit !== undefined ? Math.min(lines.length, start + limit) : lines.length;
|
|
const slice = lines.slice(start, end).join("\n");
|
|
return { content: slice };
|
|
}),
|
|
});
|
|
}
|
|
|
|
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({
|
|
filePath: params.path,
|
|
bashPermission: ctx.payload.bash,
|
|
});
|
|
const dir = dirname(resolved);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(resolved, params.content, "utf-8");
|
|
return { path: params.path, written: true };
|
|
}),
|
|
});
|
|
}
|
|
|
|
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.`,
|
|
parameters: FileEditParams,
|
|
execute: execute(async (params) => {
|
|
if (params.old_string.length === 0) {
|
|
throw new Error("old_string must not be empty");
|
|
}
|
|
if (params.old_string === params.new_string) {
|
|
throw new Error("old_string and new_string are identical");
|
|
}
|
|
|
|
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;
|
|
|
|
if (count === 0) {
|
|
throw new Error(`old_string not found in ${params.path}`);
|
|
}
|
|
if (count > 1 && !params.replace_all) {
|
|
throw new Error(
|
|
`old_string found ${count} times in ${params.path}. Set replace_all to replace all occurrences, or include more context to make the match unique.`
|
|
);
|
|
}
|
|
|
|
const updated = params.replace_all
|
|
? content.replaceAll(params.old_string, params.new_string)
|
|
: content.replace(params.old_string, params.new_string);
|
|
|
|
writeFileSync(resolved, updated, "utf-8");
|
|
return { path: params.path, replacements: params.replace_all ? count : 1 };
|
|
}),
|
|
});
|
|
}
|
|
|
|
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({
|
|
filePath: params.path,
|
|
bashPermission: ctx.payload.bash,
|
|
});
|
|
unlinkSync(resolved);
|
|
return { path: params.path, deleted: true };
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function ListDirectoryTool(_ctx: ToolContext) {
|
|
return tool({
|
|
name: "list_directory",
|
|
description: `List files and directories. Path is relative to the repository root. Only paths within the current repository are allowed. Returns entries sorted with directories first, then alphabetically.`,
|
|
parameters: ListDirectoryParams,
|
|
execute: execute(async (params) => {
|
|
const resolved = resolveAndValidatePath(params.path);
|
|
const entries = readdirSync(resolved, { withFileTypes: true });
|
|
const sorted = entries.sort((a, b) => {
|
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
const listing = sorted.map((e) => (e.isDirectory() ? `[DIR] ${e.name}` : e.name)).join("\n");
|
|
return { listing };
|
|
}),
|
|
});
|
|
}
|