diff --git a/src/tool/builtin/bash/permissions.ts b/src/tool/builtin/bash/permissions.ts index 3981cff1..c087a19b 100644 --- a/src/tool/builtin/bash/permissions.ts +++ b/src/tool/builtin/bash/permissions.ts @@ -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; @@ -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", diff --git a/tests/tool/execution/PlanModeRuntimeConstraints.test.ts b/tests/tool/execution/PlanModeRuntimeConstraints.test.ts index ccd5c138..2d02e4e2 100644 --- a/tests/tool/execution/PlanModeRuntimeConstraints.test.ts +++ b/tests/tool/execution/PlanModeRuntimeConstraints.test.ts @@ -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;