From 5628c5a790eb67733ae30272ccbcf0188dc180ff Mon Sep 17 00:00:00 2001 From: Andres Caicedo Date: Tue, 31 Mar 2026 11:09:42 -0500 Subject: [PATCH] feat(opencode): auto-approve safe bash commands using tree-sitter classification Add a classify() function that analyzes the parsed command AST to determine if a bash command is safe to auto-approve without prompting the user. Uses three-tier classification: 1. Dangerous patterns override everything (pipe to shell, rm -rf /) 2. Dangerous command blacklist (rm, dd, kill, shutdown) 3. Safe command whitelist (ls, cat, grep, find, git status, etc.) Context-aware subcommand checks for git, npm, pnpm, yarn, bun, go, and cargo to distinguish safe subcommands from destructive ones. Commands classified as safe skip the permission prompt entirely, dramatically improving UX during codebase exploration and read-only operations. Closes #20298 --- packages/opencode/src/tool/bash.ts | 265 ++++++++++++++++++++++++++++- 1 file changed, 263 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 50aa9e14ad76..2e3e05c3b13d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -48,6 +48,261 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) +// Commands that are inherently safe to auto-approve (read-only, no side effects) +const SAFE_COMMANDS = new Set([ + "ls", + "dir", + "find", + "locate", + "which", + "where", + "type", + "file", + "stat", + "wc", + "head", + "tail", + "less", + "more", + "strings", + "grep", + "egrep", + "fgrep", + "zgrep", + "awk", + "sed", + "cut", + "sort", + "uniq", + "tr", + "tee", + "diff", + "cmp", + "comm", + "tree", + "du", + "df", + "free", + "top", + "ps", + "uptime", + "whoami", + "id", + "uname", + "date", + "time", + "echo", + "printf", + "jq", + "yq", + "xmllint", + "md5sum", + "sha1sum", + "sha256sum", + "shasum", + "base64", + "xxd", + "hexdump", + "od", + "man", + "info", + "help", + "true", + "false", + "test", + "seq", + "shuf", + "paste", + "join", + "xargs", + "env", + "printenv", + "set", + "pwd", + "dirs", + "popd", + "readlink", + "realpath", + "basename", + "dirname", + "lsblk", + "lscpu", + "lsmem", + "lsusb", + "lspci", + "ip", + "ifconfig", + "netstat", + "ss", + "route", + "cat", + "bat", + "rg", + "fd", + "exa", + "eza", + "ping", + "dig", + "nslookup", + "host", + // PowerShell read-only equivalents + "get-childitem", + "get-content", + "select-string", + "get-location", + "get-process", + "get-service", + "get-eventlog", + "measure-object", + "format-list", + "format-table", + "out-null", + "write-host", + "write-output", +]) + +// Commands that are never safe to auto-approve +const DANGEROUS_COMMANDS = new Set([ + "rm", + "del", + "remove-item", + "rmdir", + "dd", + "mkfs", + "fdisk", + "parted", + "shutdown", + "reboot", + "halt", + "poweroff", + "kill", + "killall", + "pkill", + "chmod", + "chown", + "chgrp", + "format", + "diskpart", +]) + +// Patterns that make any command unsafe regardless of the command name +const DANGEROUS_PATTERNS = [ + /\|\s*(sh|bash|zsh|fish|pwsh|powershell|cmd)\b/, + /--no-preserve-root/, + /-rf\s+\/\s*$/, + />\s*\/dev\/sd/, + />\s*\/dev\/hd/, +] + +/** + * Classify whether a bash command is safe to auto-approve. + * Uses the tree-sitter parsed AST for accurate command identification. + * Returns true only if all commands in the input are read-only and + * contain no dangerous patterns. + */ +function classify(root: Node, ps: boolean): boolean { + const cmds = commands(root) + if (cmds.length === 0) return true + + for (const node of cmds) { + const command = parts(node) + const tokens = command.map((item) => item.text) + const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + if (!cmd) continue + + const sourceText = source(node) + + // Check dangerous patterns first — these override everything + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(sourceText)) return false + } + + // Explicitly dangerous commands are never auto-approved + if (DANGEROUS_COMMANDS.has(ps ? cmd.toLowerCase() : cmd)) return false + + // Command must be in the safe whitelist + if (!SAFE_COMMANDS.has(ps ? cmd.toLowerCase() : cmd)) return false + + // Context-aware subcommand checks + if (cmd === "git" && tokens.length > 1) { + const sub = tokens[1] + if ( + ![ + "status", + "log", + "diff", + "show", + "branch", + "tag", + "remote", + "stash", + "reflog", + "describe", + "shortlog", + "blame", + "ls-files", + "ls-tree", + "cat-file", + "rev-parse", + "rev-list", + "merge-base", + "name-rev", + "for-each-ref", + ].includes(sub) + ) + return false + } + + if (["npm", "pnpm", "yarn", "bun"].includes(cmd) && tokens.length > 1) { + if ( + ![ + "list", + "ls", + "info", + "view", + "search", + "explain", + "fund", + "outdated", + "version", + "--version", + "--help", + "run", + "exec", + ].includes(tokens[1]) + ) + return false + } + + if (cmd === "go" && tokens.length > 1) { + if (!["version", "env", "list", "doc", "help"].includes(tokens[1])) return false + } + + if (cmd === "cargo" && tokens.length > 1) { + if ( + ![ + "version", + "help", + "search", + "doc", + "metadata", + "tree", + "verify-project", + "read-manifest", + "generate-lockfile", + "pkgid", + ].includes(tokens[1]) + ) + return false + } + + // curl/wget are safe only if output is not piped to a shell + if (["curl", "wget"].includes(cmd) && /\|\s*(sh|bash|zsh)/.test(sourceText)) return false + } + + return true +} + type Part = { type: string text: string @@ -262,7 +517,13 @@ async function parse(command: string, ps: boolean) { return tree.rootNode } -async function ask(ctx: Tool.Context, scan: Scan) { +async function ask(ctx: Tool.Context, scan: Scan, root: Node, ps: boolean) { + // Auto-approve commands classified as safe (read-only, no dangerous patterns) + if (classify(root, ps)) { + log.info("auto-approved safe command", { command: source(root) }) + return + } + if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) @@ -481,7 +742,7 @@ export const BashTool = Tool.define("bash", async () => { const root = await parse(params.command, ps) const scan = await collect(root, cwd, ps, shell) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await ask(ctx, scan) + await ask(ctx, scan, root, ps) return run( {