Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/tool/builtin/bash/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export function classifyBashPermission(command: string): PermissionResult {
}

export function isReadOnlyShellCommand(command: string): boolean {
if (hasShellControlOperator(command) || hasWriteCapableNodeEval(command)) {
return false;
}

const tokens = tokenizeSimpleShell(command);
if (!tokens || tokens.length === 0) {
return false;
Expand Down Expand Up @@ -126,6 +130,69 @@ export function isReadOnlyShellCommand(command: string): boolean {
return normalizedCommandName === "sh" && args.length === 2 && args[0] === "-c" && /^exit\s+\d+$/.test(args[1]);
}

function hasShellControlOperator(command: string): boolean {
let quote: "'" | '"' | undefined;
let escaped = false;

for (let i = 0; i < command.length; i += 1) {
const char = command[i]!;
const next = command[i + 1];

if (escaped) {
escaped = false;
continue;
}

if (quote) {
if (char === quote) {
quote = undefined;
continue;
}
if (char === "\\" && quote === '"') {
escaped = true;
}
continue;
}

if (char === "'" || char === '"') {
quote = char;
continue;
}

if (char === "\\") {
escaped = true;
continue;
}

if (char === ">" || char === "<" || char === "|" || char === ";" || char === "&") {
return true;
}
if (char === "$" && next === "(") {
return true;
}
if (char === "`") {
return true;
}
}

return false;
}

const NODE_EVAL_WRITE_PATTERNS: RegExp[] = [
/\bfs\s*\.\s*(?:writeFile(?:Sync)?|appendFile(?:Sync)?|createWriteStream|rm(?:Sync)?|unlink(?:Sync)?|rmdir(?:Sync)?|mkdir(?:Sync)?|cp(?:Sync)?|copyFile(?:Sync)?|rename(?:Sync)?|truncate(?:Sync)?)\s*\(/,
/\brequire\s*\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)\s*\.\s*(?:writeFile|appendFile|rm|unlink|rmdir|mkdir|cp|copyFile|rename|truncate)\s*\(/,
/\bimport\s*\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)/,
];

function hasWriteCapableNodeEval(command: string): boolean {
const match = /\bnode(?:\.exe)?\b[\s\S]*?(?:^|\s)(?:-[A-Za-z]*e[A-Za-z]*|--eval)(?:=|\s+)([\s\S]+)/i.exec(command);
if (!match) {
return false;
}
const evalSource = match[1] ?? "";
return NODE_EVAL_WRITE_PATTERNS.some((pattern) => pattern.test(evalSource));
}

const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([
"--namespace",
"--super-prefix",
Expand Down
32 changes: 32 additions & 0 deletions tests/tool/execution/PlanModeRuntimeConstraints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,38 @@ test("plan mode blocks side-effecting bash commands without asking permission",
assert.match(textOf(result), /mkdir build-output/);
});

test("plan mode blocks bash read-only classifier bypasses", async () => {
const registry = new ToolRegistry();
registry.register(createBashTool({
runner: {
async run() {
throw new Error("bash runner should not be reached");
},
},
}));

const commands = [
"echo hello > plan-output.txt",
"printf hello >> plan-output.txt",
"cat package.json | tee copied-package.json",
"pwd; touch plan-output.txt",
"node -e \"require('fs').writeFileSync('plan-output.txt', 'hello')\"",
"node --eval=\"fs.appendFileSync('plan-output.txt', 'hello')\"",
];

for (const command of commands) {
const result = await new ToolRuntime(registry, new PermissionRuntime()).execute(
{ id: `call-${command}`, name: "bash", input: { command } },
createPlanContext(),
);

assert.equal(result.type, "error", command);
if (result.type !== "error") continue;
assert.equal(result.error.code, "plan_mode_violation", command);
assert.match(textOf(result), /READ-ONLY commands/, command);
}
});

test("plan mode allows read-only bash commands", async () => {
const registry = new ToolRegistry();
let executedCommand: string | undefined;
Expand Down