import * as path from "node:path"; import type { ExecutionUnit } from "./bash-ast.js"; import { analyzeBash } from "./bash-ast.js"; import type { PermissionRequest } from "./types.js"; /** Commands that only read state/standard input; used by `PermissionService.isReadOnly`. */ const READ_ONLY_COMMANDS = new Set([ "[", "basename", "cat", "cmp", "cut", "df", "diff", "dirname", "du", "echo", "false", "file", "find", "grep", "head", "jq", "ls", "md5sum", "pwd", "readlink", "realpath", "rg", "sha256sum", "sort", "stat", "tail", "test", "tr", "tree", "true", "type", "uniq", "wc", "which", ]); const SAFE_GIT_SUBCOMMANDS = new Set([ "blame", "describe", "diff", "grep", "log", "ls-files", "rev-parse", "show", "show-ref", "status", ]); const FIND_MUTATING_FLAGS = new Set([ "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls", ]); /** Shell interpreters whose `-c` script is flattened into separate units. */ const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish", "mksh", "oksh"]); function isReadOnlyGit(args: readonly string[]): boolean { const index = args.findIndex((arg) => !arg.startsWith("-")); if (index === -1) { return false; } const subcommand = args[index] ?? ""; if (SAFE_GIT_SUBCOMMANDS.has(subcommand)) { return true; } if (subcommand === "branch") { // `git branch` / `git branch -a` / `git branch --show-current` only read. return args.slice(index + 1).every((arg) => arg.startsWith("-")); } return false; } function unitIsReadOnly(unit: ExecutionUnit): boolean { if (unit.redirections.some((redirection) => redirection.mode === "write")) { return false; } const target = unit.name; const base = path.basename(target); const args = unit.targetArgv.slice(1); if (base === "eval" || base === "exec") { // Its argument was parsed into its own units, which are checked separately. return true; } if (SHELL_INTERPRETERS.has(base)) { // Only `sh -c '…'` is flattened into units. `sh script.sh`, bare `sh` and here-document // input run code the analysis never saw, so they are not read-only. const cIndex = args.findIndex((arg) => arg === "-c" || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg)); return cIndex !== -1 && args[cIndex + 1] !== undefined; } if (target === "find") { return !args.some((arg) => FIND_MUTATING_FLAGS.has(arg) || arg.startsWith("-fprint") || arg.startsWith("-exec")); } if (target === "sort" || target === "tree") { // `sort -o file` and `tree -o file` write even though the command otherwise looks read-only. return !args.some((arg) => arg === "-o" || arg.startsWith("-o") || arg.startsWith("--output")); } if (target === "sed") { return !args.some((arg) => arg === "-i" || arg.startsWith("-i") || arg.startsWith("--in-place")); } if (target === "git") { if ( args.some( (arg) => arg === "-o" || arg.startsWith("--output") || arg === "--ext-diff" || arg === "--exec-path" || arg.startsWith("--exec-path="), ) ) { return false; } return isReadOnlyGit(args); } return READ_ONLY_COMMANDS.has(target); } /** Conservative read-only check for integrations (audit, subagent policies): unknown commands are treated as mutations. */ export function commandIsReadOnly(command: string, cwd: string = process.cwd()): boolean { if (command.trim().length === 0) { return false; } const analysis = analyzeBash(command, cwd); // An unparsed or unavailable analysis must never be reported as read-only. if (!analysis || analysis.unverifiable || analysis.tooComplex || analysis.units.length === 0) { return false; } return analysis.units.every(unitIsReadOnly); } /** Non-bash requests are outside this check; bash without a command is treated as non-mutating. */ export function isReadOnlyRequest(req: PermissionRequest): boolean { if (req.tool.toLowerCase() !== "bash" || typeof req.input.command !== "string") { return true; } return commandIsReadOnly(req.input.command, req.cwd); }