diff --git a/src/utils/permissions/bashCommandSafety.test.ts b/src/utils/permissions/bashCommandSafety.test.ts new file mode 100644 index 0000000000..4ee3d5f6c4 --- /dev/null +++ b/src/utils/permissions/bashCommandSafety.test.ts @@ -0,0 +1,533 @@ +import { describe, expect, test } from 'bun:test' + +import { classifyBashSafety } from './bashCommandSafety.ts' + +function safety(cmd: string): 'safe' | 'unsafe' | 'unknown' { + return classifyBashSafety(cmd).safety +} + +describe('empty / edge cases', () => { + test('empty string is safe', () => { + expect(safety('')).toBe('safe') + }) + + test('whitespace only is safe', () => { + expect(safety(' \t\n ')).toBe('safe') + }) +}) + +describe('always-safe commands', () => { + test('pwd is safe', () => { + expect(safety('pwd')).toBe('safe') + }) + + test('whoami is safe', () => { + expect(safety('whoami')).toBe('safe') + }) + + test('date is safe', () => { + expect(safety('date')).toBe('safe') + }) + + test('uname -a is safe', () => { + expect(safety('uname -a')).toBe('safe') + }) +}) + +describe('arg-gated safe — read-only file reads', () => { + test('cat README.md', () => { + expect(safety('cat README.md')).toBe('safe') + }) + + test('head -n 20 file.log', () => { + expect(safety('head -n 20 file.log')).toBe('safe') + }) + + test('tail -f is safe (read-only follow)', () => { + expect(safety('tail -f app.log')).toBe('safe') + }) + + test('wc -l src/index.ts', () => { + expect(safety('wc -l src/index.ts')).toBe('safe') + }) + + test('stat package.json', () => { + expect(safety('stat package.json')).toBe('safe') + }) +}) + +describe('arg-gated safe — search', () => { + test('grep -r foo src/', () => { + expect(safety('grep -r foo src/')).toBe('safe') + }) + + test('rg foo', () => { + expect(safety('rg foo')).toBe('safe') + }) + + test('find . -type f -name *.ts', () => { + expect(safety('find . -type f -name *.ts')).toBe('safe') + }) + + test('find with -exec is UNSAFE', () => { + expect(safety('find . -name *.tmp -exec rm {} \\;')).toBe('unsafe') + }) + + test('find with -delete is UNSAFE', () => { + expect(safety('find . -name *.tmp -delete')).toBe('unsafe') + }) +}) + +describe('arg-gated safe — version queries', () => { + test('node --version', () => { + expect(safety('node --version')).toBe('safe') + }) + + test('npm --version', () => { + expect(safety('npm --version')).toBe('safe') + }) + + test('bun -v', () => { + expect(safety('bun -v')).toBe('safe') + }) + + test('python3 --version', () => { + expect(safety('python3 --version')).toBe('safe') + }) + + test('cargo --version', () => { + expect(safety('cargo --version')).toBe('safe') + }) + + test('go version', () => { + expect(safety('go version')).toBe('safe') + }) + + test('tsc --noEmit', () => { + expect(safety('tsc --noEmit')).toBe('safe') + }) +}) + +describe('git — read vs write', () => { + test('git status', () => { + expect(safety('git status')).toBe('safe') + }) + + test('git log --oneline', () => { + expect(safety('git log --oneline')).toBe('safe') + }) + + test('git diff', () => { + expect(safety('git diff')).toBe('safe') + }) + + test('git show HEAD', () => { + expect(safety('git show HEAD')).toBe('safe') + }) + + test('git branch -v', () => { + expect(safety('git branch -v')).toBe('safe') + }) + + test('git fetch is safe (metadata only)', () => { + expect(safety('git fetch')).toBe('safe') + }) + + test('git blame src/index.ts', () => { + expect(safety('git blame src/index.ts')).toBe('safe') + }) + + test('git branch -D doomed is UNSAFE', () => { + expect(safety('git branch -D doomed')).toBe('unsafe') + }) + + test('git commit is UNSAFE', () => { + expect(safety('git commit -m fix')).toBe('unsafe') + }) + + test('git push is UNSAFE', () => { + expect(safety('git push')).toBe('unsafe') + }) + + test('git reset --hard is UNSAFE', () => { + expect(safety('git reset --hard')).toBe('unsafe') + }) + + test('git checkout is UNSAFE', () => { + expect(safety('git checkout main')).toBe('unsafe') + }) +}) + +describe('dangerous commands', () => { + test('rm -rf /tmp/x is unsafe', () => { + expect(safety('rm -rf /tmp/x')).toBe('unsafe') + }) + + test('dd if=/dev/zero of=/dev/sda is unsafe', () => { + expect(safety('dd if=/dev/zero of=/dev/sda')).toBe('unsafe') + }) + + test('sudo anything is unsafe', () => { + expect(safety('sudo ls')).toBe('unsafe') + }) + + test('chmod 777 file is unsafe', () => { + expect(safety('chmod 777 file')).toBe('unsafe') + }) + + test('kill 12345 is unsafe', () => { + expect(safety('kill 12345')).toBe('unsafe') + }) + + test('shutdown -h is unsafe', () => { + expect(safety('shutdown -h now')).toBe('unsafe') + }) + + test('eval $FOO is unsafe', () => { + expect(safety('eval $FOO')).toBe('unsafe') + }) + + test('source rc is unsafe', () => { + expect(safety('source ~/.bashrc')).toBe('unsafe') + }) +}) + +describe('network commands', () => { + test('curl is unsafe', () => { + expect(safety('curl https://example.com')).toBe('unsafe') + }) + + test('wget is unsafe', () => { + expect(safety('wget https://example.invalid/path')).toBe('unsafe') + }) + + test('ssh is unsafe', () => { + expect(safety('ssh user@host echo hi')).toBe('unsafe') + }) + + test('rsync is unsafe', () => { + expect(safety('rsync -av src/ dest/')).toBe('unsafe') + }) +}) + +describe('mutating commands', () => { + test('mv is unsafe', () => { + expect(safety('mv a b')).toBe('unsafe') + }) + + test('cp is unsafe', () => { + expect(safety('cp a b')).toBe('unsafe') + }) + + test('mkdir is unsafe', () => { + expect(safety('mkdir new-dir')).toBe('unsafe') + }) + + test('touch is unsafe', () => { + expect(safety('touch newfile')).toBe('unsafe') + }) + + test('sed -i is unsafe', () => { + expect(safety("sed -i 's/a/b/' file.txt")).toBe('unsafe') + }) + + test('sed without -i is unknown (may be piped)', () => { + expect(safety("sed 's/a/b/' file.txt")).toBe('unknown') + }) +}) + +describe('package managers — install is unsafe', () => { + test('npm install X', () => { + expect(safety('npm install express')).toBe('unsafe') + }) + + test('npm ci', () => { + expect(safety('npm ci')).toBe('unsafe') + }) + + test('npm run test', () => { + expect(safety('npm run test')).toBe('unsafe') + }) + + test('npm --version is safe', () => { + expect(safety('npm --version')).toBe('safe') + }) + + test('npm list is safe', () => { + expect(safety('npm list')).toBe('safe') + }) + + test('bun add X is unsafe', () => { + expect(safety('bun add typescript')).toBe('unsafe') + }) + + test('bun --version is safe', () => { + expect(safety('bun --version')).toBe('safe') + }) + + test('pnpm install is unsafe', () => { + expect(safety('pnpm install')).toBe('unsafe') + }) +}) + +describe('file redirection degrades to unknown', () => { + test('echo foo > file.txt is unknown', () => { + expect(safety('echo foo > file.txt')).toBe('unknown') + }) + + test('cat x >> y is unknown', () => { + expect(safety('cat x >> y')).toBe('unknown') + }) + + test('cmd 2> errors.log is unknown', () => { + expect(safety('ls 2> errors.log')).toBe('unknown') + }) +}) + +describe('command substitution is unknown', () => { + test('echo $(rm -rf /) is unknown (substitution not evaluated)', () => { + expect(safety('echo $(date)')).toBe('unknown') + }) + + test('backtick substitution is unknown', () => { + expect(safety('echo `date`')).toBe('unknown') + }) +}) + +describe('compound commands', () => { + test('all safe parts → safe', () => { + expect(safety('git status && git log --oneline')).toBe('safe') + }) + + test('one unsafe part → unsafe', () => { + expect(safety('git status && git push')).toBe('unsafe') + }) + + test('one unknown part + safe parts → unknown', () => { + expect(safety("git status && sed 's/a/b/' file.txt")).toBe('unknown') + }) + + test('unsafe beats unknown', () => { + expect(safety("sed 's/a/b/' file && rm foo")).toBe('unsafe') + }) + + test('pipe between safe and unsafe → unsafe', () => { + expect(safety('ls | rm')).toBe('unsafe') + }) + + test('pipe between two safe is safe', () => { + expect(safety('cat file.txt | head -5')).toBe('safe') + }) + + test('semicolon separator', () => { + expect(safety('pwd; whoami; date')).toBe('safe') + }) +}) + +describe('quoting is respected', () => { + test('quoted && does not split', () => { + expect(safety('echo "a && b"')).toBe('safe') + }) + + test('quoted > does not trigger redirect', () => { + expect(safety('echo "a > b"')).toBe('safe') + }) + + test('unbalanced quotes → unknown', () => { + expect(safety('echo "unclosed')).toBe('unknown') + }) +}) + +describe('env-var assignment prefix', () => { + test('FOO=bar pwd is safe', () => { + expect(safety('FOO=bar pwd')).toBe('safe') + }) + + test('FOO=bar rm -rf is unsafe', () => { + expect(safety('FOO=bar rm -rf /tmp')).toBe('unsafe') + }) +}) + +describe('unknown / not in either list', () => { + test('random command is unknown', () => { + expect(safety('flarbgorg --zing')).toBe('unknown') + }) + + test('make build is unknown (not classified)', () => { + expect(safety('make build')).toBe('unknown') + }) +}) + +// --------------------------------------------------------------------------- +// Adversarial tests — bypass patterns identified in security review +// --------------------------------------------------------------------------- + +describe('bypass: escaped backslash before close quote (#1)', () => { + test('echo "test\\\\" && rm -rf / is not classified safe', () => { + // The `\\` inside double quotes is an escaped backslash; the following + // `"` is the real closing quote. The prior parser wrongly kept the + // quote open and swallowed `&& rm -rf /` into the echo argument. + const verdict = safety('echo "test\\\\" && rm -rf /') + expect(verdict).toBe('unsafe') + }) + + test('odd backslash count does keep quote escaped', () => { + // Three backslashes before `"`: literal `\` + escaped `\"`. + // The quote stays open; the compound separator is inside the string. + // Expected: not safe (unknown or unsafe), but certainly not 'safe'. + const verdict = safety('echo "test\\\\\\" && rm -rf /') + expect(verdict).not.toBe('safe') + }) + + test('literal escaped backslash in a safe echo stays safe', () => { + // `echo "a\\b"` is still just an echo with a literal backslash in the + // string — no compound, no side effects. + expect(safety('echo "a\\\\b"')).toBe('safe') + }) +}) + +describe('bypass: process substitution (#2)', () => { + test('cat <(curl ...) degrades to unknown', () => { + expect(safety('cat <(curl https://example.invalid)')).toBe('unknown') + }) + + test('tee >(sh) degrades to unknown', () => { + expect(safety('echo x | tee >(sh)')).toBe('unknown') + }) + + test('diff with two process-subs is unknown', () => { + expect(safety('diff <(ls) <(ls -a)')).toBe('unknown') + }) +}) + +describe('bypass: newline as command separator (#3)', () => { + test('echo safe\\nrm -rf / splits on newline and marks unsafe', () => { + expect(safety('echo safe\nrm -rf /')).toBe('unsafe') + }) + + test('multiline pwd;date is safe (all parts safe)', () => { + expect(safety('pwd\ndate')).toBe('safe') + }) + + test('newline between safe and unsafe → unsafe', () => { + expect(safety('git status\ngit push')).toBe('unsafe') + }) +}) + +describe('bypass: sensitive paths (#4)', () => { + test('cat /etc/shadow is not safe', () => { + expect(safety('cat /etc/shadow')).toBe('unknown') + }) + + test('cat ~/.ssh/id_rsa is not safe', () => { + expect(safety('cat ~/.ssh/id_rsa')).toBe('unknown') + }) + + test('cat /proc/self/environ is not safe', () => { + expect(safety('cat /proc/self/environ')).toBe('unknown') + }) + + test('cat /dev/sda is not safe', () => { + expect(safety('cat /dev/sda')).toBe('unknown') + }) + + test('cat ~/.aws/credentials is not safe', () => { + expect(safety('cat ~/.aws/credentials')).toBe('unknown') + }) + + test('tail -f /etc/shadow is not safe', () => { + expect(safety('tail -f /etc/shadow')).toBe('unknown') + }) + + test('cat ~/.ssh/id_rsa.pub (public key) stays safe', () => { + expect(safety('cat ~/.ssh/id_rsa.pub')).toBe('safe') + }) + + test('cat README.md stays safe', () => { + expect(safety('cat README.md')).toBe('safe') + }) +}) + +describe('bypass: git config set (#5)', () => { + test('git config user.email "x" writes config — unsafe', () => { + expect(safety('git config user.email attacker@evil.invalid')).toBe('unsafe') + }) + + test('git config user.email (1 arg, read form) is not safe-auto-approved', () => { + // Single-arg form is a read — classifier drops `config` from the + // read-only list entirely, so it falls to 'unknown' (deferred to the + // existing permission rule system). + expect(safety('git config user.email')).toBe('unknown') + }) + + test('git config --get user.email is unknown (read, not auto-approved)', () => { + expect(safety('git config --get user.email')).toBe('unknown') + }) + + test('git config --unset user.email is unsafe', () => { + expect(safety('git config --unset user.email')).toBe('unsafe') + }) +}) + +describe('bypass: git stash (#6)', () => { + test('bare git stash (= push) is unsafe', () => { + expect(safety('git stash')).toBe('unsafe') + }) + + test('git stash push -m msg is unsafe', () => { + expect(safety('git stash push -m "wip"')).toBe('unsafe') + }) + + test('git stash list stays safe', () => { + expect(safety('git stash list')).toBe('safe') + }) + + test('git stash show stays safe', () => { + expect(safety('git stash show')).toBe('safe') + }) + + test('git stash drop is unsafe', () => { + expect(safety('git stash drop')).toBe('unsafe') + }) +}) + +describe('hygiene: env/printenv (#7)', () => { + test('env alone is not auto-approved (would leak all env vars)', () => { + expect(safety('env')).toBe('unknown') + }) + + test('printenv alone is not auto-approved', () => { + expect(safety('printenv')).toBe('unknown') + }) + + test('FOO=bar pwd still works via env-assignment-prefix stripping', () => { + // This is the safe "run pwd with an extra env var" idiom. + expect(safety('FOO=bar pwd')).toBe('safe') + }) +}) + +describe('hygiene: git gc / bisect / prune / repack (#8)', () => { + test('git gc is unsafe (deletes loose objects)', () => { + expect(safety('git gc')).toBe('unsafe') + }) + + test('git prune is unsafe', () => { + expect(safety('git prune')).toBe('unsafe') + }) + + test('git repack is unsafe', () => { + expect(safety('git repack -a')).toBe('unsafe') + }) + + test('git bisect start is unsafe', () => { + expect(safety('git bisect start HEAD HEAD~10')).toBe('unsafe') + }) + + test('git bisect good is unsafe', () => { + expect(safety('git bisect good')).toBe('unsafe') + }) + + test('git bisect reset is unsafe', () => { + expect(safety('git bisect reset')).toBe('unsafe') + }) +}) diff --git a/src/utils/permissions/bashCommandSafety.ts b/src/utils/permissions/bashCommandSafety.ts new file mode 100644 index 0000000000..ef236acbcf --- /dev/null +++ b/src/utils/permissions/bashCommandSafety.ts @@ -0,0 +1,809 @@ +/** + * Pattern-based safety classifier for Bash commands. + * + * Complements the existing tool-level allowlist (classifierDecision.ts — + * which bypasses permission checks for inherently-safe tools like Read, + * Glob, Grep) by classifying actual `bash` command strings. Returns one + * of three verdicts: + * + * - 'safe' — obviously read-only / idempotent / no external effect; + * callers may auto-approve without prompting. + * - 'unsafe' — matches a known-destructive or network-sensitive pattern; + * callers should never auto-approve; always prompt or deny. + * - 'unknown' — neither list matched; defer to the existing allow/deny + * rule system (no opinion). + * + * Design: + * - Pure pattern matching: no LLM call, no latency, no hallucination. + * - Bias toward false-negatives ('unknown') over false-positives ('safe'). + * It's better to prompt once too often than to auto-approve `rm -rf`. + * - Compound commands (&&, ||, ;, |, subshells) are split and classified + * individually. The overall verdict is the *worst* case: + * any 'unsafe' → 'unsafe' + * any 'unknown' (with no 'unsafe') → 'unknown' + * all 'safe' → 'safe' + * - Any I/O redirection to a real file (>, >>, tee -a FILE) degrades to + * 'unknown' since it mutates state outside the current process. + * + * This module intentionally does NOT read env, check cwd, or consult the + * permission rules system — it returns a verdict that those systems can + * consume. Keeping it pure makes it trivial to test and reason about. + */ + +export type CommandSafety = 'safe' | 'unsafe' | 'unknown' + +export type SafetyVerdict = { + safety: CommandSafety + /** One-line reason. Useful for logs and a future UI indicator. */ + reason: string + /** When compound, the per-subcommand verdicts that produced this result. */ + parts?: SafetyVerdict[] +} + +// --------------------------------------------------------------------------- +// Safe allowlist: command patterns that are read-only, version queries, +// metadata dumps, or harmless echo. Matched against the first token of each +// sub-command AND, for some commands, the full argument list. +// --------------------------------------------------------------------------- + +// Tools whose every invocation is inherently read-only regardless of args. +// NOTE: env and printenv are intentionally NOT in this set — they dump all +// environment variables, which may contain API keys / tokens. They fall to +// 'unknown' so the existing permission rule system can decide. +const ALWAYS_SAFE_COMMANDS = new Set([ + 'pwd', + 'whoami', + 'hostname', + 'uname', + 'date', + 'id', + 'groups', + 'tty', + 'uptime', + 'arch', + 'nproc', + 'which', + 'command', + 'type', + 'whereis', + 'true', + 'false', +]) + +// Sensitive file paths that must never be auto-approved even via read-only +// commands (cat, head, tail, less, more, file, stat, wc). Matched against +// each argument; a single match degrades the whole invocation to 'unknown'. +const SENSITIVE_PATH_PATTERNS: readonly RegExp[] = [ + /^\/etc\/(shadow|gshadow|master\.passwd|sudoers(\/|$))/, + /(^|\/)\.ssh\/(?!.*\.pub$)/, // .ssh/ contents except .pub keys + /(^|\/)\.aws\/credentials$/, + /(^|\/)\.aws\/config$/, + /(^|\/)\.config\/gcloud\//, + /(^|\/)\.kube\/config$/, + /(^|\/)\.netrc$/, + /(^|\/)\.npmrc$/, + /(^|\/)\.pypirc$/, + /(^|\/)\.docker\/config\.json$/, + /^\/proc\/[^/]+\/(environ|mem|maps|kcore)$/, + /^\/proc\/kcore$/, + /^\/dev\/(sd[a-z]|nvme\d|disk\d|mmcblk|mapper\/)/, + /^\/dev\/(mem|kmem|port|tty\d+)$/, + /(^|\/)\.git-credentials$/, + // Generic private-key patterns anywhere on disk + /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/, + /\.pem$/, + /\.key$/, +] + +function hasSensitivePath(args: readonly string[]): boolean { + return args.some(arg => SENSITIVE_PATH_PATTERNS.some(re => re.test(arg))) +} + +// Tools that are safe for specific argument shapes. Each predicate takes the +// argument list (tokens after the command name) and returns true when the +// invocation is safe. +const ARG_GATED_SAFE: Record boolean> = { + // Read-only file reads — but NOT tee (writes), NOT `cat > FILE` (redirect + // is handled separately at the compound level), and NOT when any argument + // points to a known-sensitive path (credentials, private keys, /dev, etc.). + cat: args => args.length >= 1 && !args.some(isMutatingFlag) && !hasSensitivePath(args), + head: args => args.length >= 1 && !args.some(isMutatingFlag) && !hasSensitivePath(args), + tail: args => args.length >= 1 && !args.some(isMutatingFlag) && !hasSensitivePath(args), + less: args => args.length >= 1 && !args.some(isMutatingFlag) && !hasSensitivePath(args), + more: args => args.length >= 1 && !args.some(isMutatingFlag) && !hasSensitivePath(args), + file: args => args.length >= 1 && !hasSensitivePath(args), + wc: args => args.length >= 1 && !hasSensitivePath(args), + stat: args => args.length >= 1 && !hasSensitivePath(args), + basename: args => args.length >= 1, + dirname: args => args.length >= 1, + realpath: args => args.length >= 1 && !hasSensitivePath(args), + readlink: args => args.length >= 1 && !hasSensitivePath(args), + + // Listing / inspection + ls: args => !args.some(a => a === '-d' && args.includes('/')), + tree: () => true, + du: args => !args.some(a => a.startsWith('--delete')), + df: () => true, + + // Search — disallow -exec (arbitrary command), -delete, -print0 + xargs + find: args => + !args.some(a => a === '-exec' || a === '-execdir' || a === '-delete' || a === '-ok'), + grep: args => !args.some(a => a.startsWith('--include=/dev')), + rg: () => true, + ag: () => true, + fgrep: () => true, + egrep: () => true, + + // Version / help queries are always safe; we only auto-approve simple form + node: args => isSimpleQueryFlag(args), + npm: args => args[0] === '--version' || args[0] === '-v' || args[0] === 'help' || args[0] === 'view' || args[0] === 'list' || args[0] === 'ls' || args[0] === 'root' || args[0] === 'config' && args[1] === 'get', + bun: args => isSimpleQueryFlag(args) || args[0] === '--help', + pnpm: args => isSimpleQueryFlag(args) || args[0] === 'list' || args[0] === 'ls', + yarn: args => isSimpleQueryFlag(args) || args[0] === 'list', + python: args => isSimpleQueryFlag(args), + python3: args => isSimpleQueryFlag(args), + ruby: args => isSimpleQueryFlag(args), + go: args => isSimpleQueryFlag(args) || args[0] === 'version' || args[0] === 'env', + cargo: args => isSimpleQueryFlag(args), + rustc: args => isSimpleQueryFlag(args), + deno: args => isSimpleQueryFlag(args), + tsc: args => isSimpleQueryFlag(args) || args[0] === '--noEmit', + java: args => isSimpleQueryFlag(args), + javac: args => isSimpleQueryFlag(args), + mvn: args => isSimpleQueryFlag(args), + gradle: args => isSimpleQueryFlag(args), + + // Git — read-only subcommands with careful handling of known-mutating + // variants. Removed from this list (defer to 'unknown'): + // - config → plain `git config key value` is a set, writes .git/config + // - stash → bare `git stash` = stash push, mutates worktree/index + // - gc → repacks + deletes loose objects + // - bisect → start/good/bad mutate HEAD + git: args => { + if (args.length === 0) return true // `git` alone shows usage + const sub = args[0] + const READ_ONLY_SUBCOMMANDS = new Set([ + 'status', + 'log', + 'diff', + 'show', + 'branch', + 'tag', + 'remote', + 'rev-parse', + 'rev-list', + 'ls-files', + 'ls-remote', + 'ls-tree', + 'blame', + 'shortlog', + 'describe', + 'reflog', + 'worktree', + 'fetch', + 'help', + '--version', + '--help', + 'whatchanged', + 'cat-file', + 'show-ref', + 'symbolic-ref', + 'name-rev', + 'count-objects', + 'fsck', + 'grep', + ]) + // stash is safe only for read subcommands (list, show). Bare `git stash` + // is equivalent to `git stash push` — mutates the working tree. + if (sub === 'stash') { + const rest = args.slice(1) + return rest[0] === 'list' || rest[0] === 'show' + } + if (!READ_ONLY_SUBCOMMANDS.has(sub)) return false + // `git branch -D`, `git branch --delete`, `git tag -d`, etc. mutate. + return !args.some( + a => + a === '-D' || + a === '--delete' || + a === '--force-delete' || + a === 'drop' || + a === 'clear' || + a === '-d' && sub === 'branch' || + a === '-d' && sub === 'tag' || + a === '-m' && sub === 'stash', + ) + }, + + // Echo / print — safe because output-only (redirection is checked at the + // compound level and promotes the verdict to 'unknown' or 'unsafe'). + echo: () => true, + printf: () => true, + yes: () => true, + seq: () => true, +} + +// Mutating flags that rule out the "read-only" classification for any cmd. +function isMutatingFlag(token: string): boolean { + return ( + token === '-o' || + token === '--output' || + token === '-w' || + token === '--write' || + token === '--in-place' || + token === '-i' && /* sed -i */ false // keep simple; sed is handled elsewhere + ) +} + +function isSimpleQueryFlag(args: string[]): boolean { + if (args.length === 0) return true + const first = args[0] + return ( + first === '--version' || + first === '-v' || + first === '-V' || + first === 'version' || + first === '--help' || + first === '-h' || + first === 'help' + ) +} + +// --------------------------------------------------------------------------- +// Unsafe denylist +// --------------------------------------------------------------------------- + +const DANGEROUS_COMMANDS = new Set([ + 'rm', + 'rmdir', + 'dd', + 'shred', + 'mkfs', + 'mkfs.ext4', + 'mkfs.btrfs', + 'fdisk', + 'parted', + 'mount', + 'umount', + 'chmod', + 'chown', + 'chgrp', + 'chattr', + 'setfacl', + 'passwd', + 'useradd', + 'userdel', + 'usermod', + 'sudo', + 'su', + 'doas', + 'kill', + 'killall', + 'pkill', + 'halt', + 'reboot', + 'shutdown', + 'poweroff', + 'systemctl', + 'service', + 'launchctl', + 'crontab', + 'at', + 'eval', + 'exec', + 'source', + // Package managers that mutate system state + 'apt', + 'apt-get', + 'yum', + 'dnf', + 'pacman', + 'brew', + 'port', + 'snap', +]) + +// Network-effecting — not always destructive but exfiltration / side-effect +// risk high enough to require human approval. +const NETWORK_COMMANDS = new Set([ + 'curl', + 'wget', + 'nc', + 'netcat', + 'ncat', + 'ssh', + 'scp', + 'sftp', + 'rsync', + 'telnet', + 'ftp', +]) + +// Commands whose first arg determines mutation vs read. Map to a predicate +// that returns true when the invocation is UNSAFE. +const ARG_GATED_UNSAFE: Record boolean> = { + mv: () => true, + cp: () => true, + ln: () => true, + touch: () => true, + mkdir: () => true, + tee: () => true, // always writes somewhere + sed: args => args.some(a => a === '-i' || a === '--in-place' || a.startsWith('-i')), + awk: args => args.some(a => a === '-i'), + find: args => + args.some( + a => a === '-exec' || a === '-execdir' || a === '-delete' || a === '-ok' || a === '-okdir', + ), + docker: args => { + if (args.length === 0) return false + const sub = args[0] + return new Set([ + 'rm', + 'rmi', + 'kill', + 'stop', + 'start', + 'run', + 'exec', + 'build', + 'push', + 'pull', + 'login', + 'logout', + 'system', + 'volume', + 'network', + ]).has(sub) + }, + npm: args => { + if (args.length === 0) return false + const sub = args[0] + return new Set([ + 'install', + 'i', + 'add', + 'uninstall', + 'remove', + 'rm', + 'un', + 'publish', + 'unpublish', + 'link', + 'unlink', + 'update', + 'upgrade', + 'audit', + 'ci', + 'exec', + 'run', + 'run-script', + 'start', + 'test', + 'dedupe', + 'prune', + ]).has(sub) + }, + yarn: args => { + if (args.length === 0) return true // plain `yarn` = install + const sub = args[0] + return new Set([ + 'add', + 'remove', + 'install', + 'publish', + 'unpublish', + 'link', + 'unlink', + 'upgrade', + 'run', + 'exec', + ]).has(sub) + }, + pnpm: args => { + if (args.length === 0) return false + const sub = args[0] + return new Set([ + 'install', + 'i', + 'add', + 'remove', + 'rm', + 'update', + 'up', + 'publish', + 'link', + 'unlink', + 'exec', + 'run', + 'start', + ]).has(sub) + }, + bun: args => { + if (args.length === 0) return false + const sub = args[0] + return new Set([ + 'install', + 'i', + 'add', + 'remove', + 'rm', + 'update', + 'publish', + 'link', + 'unlink', + 'run', + ]).has(sub) + }, + git: args => { + if (args.length === 0) return false + const sub = args[0] + const MUTATING_SUBCOMMANDS = new Set([ + 'push', + 'pull', + 'commit', + 'merge', + 'rebase', + 'reset', + 'revert', + 'checkout', + 'switch', + 'restore', + 'clean', + 'add', + 'rm', + 'mv', + 'init', + 'clone', + 'submodule', + 'cherry-pick', + 'apply', + 'am', + 'format-patch', + 'request-pull', + 'send-email', + 'filter-branch', + 'filter-repo', + ]) + if (MUTATING_SUBCOMMANDS.has(sub)) return true + + // Destructive flags on otherwise-read-only subcommands. + // git branch -D / --delete / --force-delete → mutates + // git stash drop / clear / pop → mutates + // git tag -d / --delete → mutates + // git remote remove / rm / set-url → mutates + // git config --unset / --replace-all → mutates + const rest = args.slice(1) + if (sub === 'branch') { + if (rest.some(a => a === '-D' || a === '--delete' || a === '--force-delete' || a === '-d')) { + return true + } + } + if (sub === 'stash') { + if (rest.some(a => a === 'drop' || a === 'clear' || a === 'pop' || a === 'apply')) { + return true + } + } + if (sub === 'tag') { + if (rest.some(a => a === '-d' || a === '--delete')) { + return true + } + } + if (sub === 'remote') { + if (rest[0] === 'remove' || rest[0] === 'rm' || rest[0] === 'set-url' || rest[0] === 'add' || rest[0] === 'rename') { + return true + } + } + if (sub === 'config') { + // Unsafe flags — explicit mutations. + if (rest.some(a => a === '--unset' || a === '--replace-all' || a === '--add' || a === '--unset-all')) { + return true + } + // `git config key value` (2+ positional args without a read flag) + // writes to .git/config. Only the single-arg read form (`git config + // user.email`) and explicit read flags (--get / --list / -l) are not + // mutations — everything else is a set. + const positional = rest.filter(a => !a.startsWith('-')) + const isReadFlag = rest.some(a => a === '--get' || a === '--get-all' || a === '--list' || a === '-l') + if (!isReadFlag && positional.length >= 2) { + return true + } + } + if (sub === 'stash') { + // Bare `git stash` = `git stash push` → mutates. + if (rest.length === 0) return true + // Any push/save/create/store subcommand mutates. + if (rest[0] === 'push' || rest[0] === 'save' || rest[0] === 'create' || rest[0] === 'store') { + return true + } + } + if (sub === 'gc' || sub === 'prune' || sub === 'repack') { + return true + } + if (sub === 'bisect') { + // start, good, bad, reset, run, skip all mutate bisect state / HEAD. + // Allow only `git bisect log` and `git bisect visualize` through the + // read-only path — but since bisect isn't in READ_ONLY_SUBCOMMANDS + // anymore, everything falls to unknown by default; we mark the + // common mutating subcommands unsafe for explicit-deny clarity. + if ( + rest[0] === 'start' || + rest[0] === 'good' || + rest[0] === 'bad' || + rest[0] === 'reset' || + rest[0] === 'skip' || + rest[0] === 'run' || + rest[0] === 'terms' || + rest[0] === 'replay' + ) { + return true + } + } + + return false + }, +} + +// --------------------------------------------------------------------------- +// Parser — best-effort tokenization + compound splitting +// --------------------------------------------------------------------------- + +// Returns true when the character at position `i` is preceded by an odd +// number of backslashes — i.e., it is bash-escaped. `\"` has one backslash +// (odd → escaped), `\\"` has two (even → backslash escapes backslash, the +// quote stands). The prior `input[i-1] !== '\\'` check got this wrong and +// let `echo "test\\" && rm -rf /` slip through as one quoted token. +function isCharEscaped(input: string, i: number): boolean { + let count = 0 + let j = i - 1 + while (j >= 0 && input[j] === '\\') { + count++ + j-- + } + return count % 2 === 1 +} + +// Splits a command line into compound parts at &&, ||, ;, |, and newlines +// (newline is equivalent to ; in bash). Respects basic single/double quoting +// to avoid splitting inside strings, with correct escape-counting for close +// quotes. Does NOT fully evaluate shell grammar — intentionally conservative; +// anything too complex falls back to 'unknown'. +function splitCompound(input: string): string[] { + const parts: string[] = [] + let current = '' + let i = 0 + let inSingle = false + let inDouble = false + let parenDepth = 0 + while (i < input.length) { + const c = input[i] + const next = input[i + 1] + if (!inSingle && !inDouble) { + if (c === '"') { + inDouble = true + current += c + i++ + continue + } + if (c === "'") { + inSingle = true + current += c + i++ + continue + } + if (c === '(' || c === '{') { + parenDepth++ + current += c + i++ + continue + } + if (c === ')' || c === '}') { + parenDepth-- + current += c + i++ + continue + } + if (parenDepth === 0) { + if ((c === '&' && next === '&') || (c === '|' && next === '|')) { + parts.push(current) + current = '' + i += 2 + continue + } + // Newline is a statement separator in bash, equivalent to ';'. + if (c === ';' || c === '|' || c === '&' || c === '\n') { + parts.push(current) + current = '' + i++ + continue + } + } + } else { + // Single quotes in bash do not respect any escape sequence — a single + // quote always closes. Double quotes honor backslash escapes; we + // detect a true (unescaped) close quote with isCharEscaped(). + if (inDouble && c === '"' && !isCharEscaped(input, i)) inDouble = false + else if (inSingle && c === "'") inSingle = false + } + current += c + i++ + } + if (current.trim()) parts.push(current) + return parts.map(p => p.trim()).filter(Boolean) +} + +// Shell-like tokenizer. Respects ' and " quoting. Does NOT expand variables +// or handle $(...) / `...` / <(...) / >(...) substitution — if any of those +// are present, return null so the caller treats the command as 'unknown'. +// Process substitution (<( >() spawns a subprocess whose command we can't +// classify from this layer, so auto-approving is unsafe. +function tokenize(input: string): string[] | null { + const trimmed = input.trim() + if (!trimmed) return [] + if ( + trimmed.includes('$(') || + trimmed.includes('`') || + trimmed.includes('<(') || + trimmed.includes('>(') + ) { + return null // command / process substitution + } + + const tokens: string[] = [] + let current = '' + let i = 0 + let inSingle = false + let inDouble = false + while (i < trimmed.length) { + const c = trimmed[i] + if (!inSingle && !inDouble) { + if (c === '"') { + inDouble = true + i++ + continue + } + if (c === "'") { + inSingle = true + i++ + continue + } + if (/\s/.test(c)) { + if (current) { + tokens.push(current) + current = '' + } + i++ + continue + } + } else { + // Double quotes honor backslash escapes in bash — a `\"` is a literal + // quote, and the string keeps going. Use isCharEscaped() to count + // consecutive backslashes so `\\"` (even) correctly closes the quote. + if (inDouble && c === '"' && !isCharEscaped(trimmed, i)) { + inDouble = false + i++ + continue + } + // Single quotes are literal — no escape handling in bash. + if (inSingle && c === "'") { + inSingle = false + i++ + continue + } + } + current += c + i++ + } + if (inSingle || inDouble) return null // unbalanced quotes + if (current) tokens.push(current) + return tokens +} + +function hasFileRedirect(tokens: string[]): boolean { + // >, >>, &>, 2>, 2>>, < (< is input redirect, safe-ish but we conservatively + // treat it as unknown if piping into a mutating context) + return tokens.some(t => + /^(&?>|>>|2>|2>>|&>>)$/.test(t) || /(?\S/.test(t), + ) +} + +function classifyLeaf(command: string): SafetyVerdict { + const trimmed = command.trim() + if (!trimmed) { + return { safety: 'safe', reason: 'empty' } + } + + const tokens = tokenize(trimmed) + if (tokens === null) { + return { + safety: 'unknown', + reason: 'contains command substitution or unbalanced quotes', + } + } + if (tokens.length === 0) { + return { safety: 'safe', reason: 'empty' } + } + + if (hasFileRedirect(tokens)) { + return { safety: 'unknown', reason: 'file redirection (> / >>)' } + } + + const [cmd, ...args] = tokens + + // Strip leading env-var assignments like FOO=bar cmd... + let actualCmd = cmd + let actualArgs = args + if (/^[A-Z_][A-Z0-9_]*=/.test(cmd)) { + // skip env-assignment prefix tokens + let idx = 0 + while (idx < tokens.length && /^[A-Z_][A-Z0-9_]*=/.test(tokens[idx])) idx++ + if (idx >= tokens.length) { + return { safety: 'safe', reason: 'env assignment only' } + } + actualCmd = tokens[idx] + actualArgs = tokens.slice(idx + 1) + } + + if (DANGEROUS_COMMANDS.has(actualCmd)) { + return { safety: 'unsafe', reason: `${actualCmd} is on the dangerous list` } + } + if (NETWORK_COMMANDS.has(actualCmd)) { + return { + safety: 'unsafe', + reason: `${actualCmd} performs network I/O — requires explicit approval`, + } + } + + const unsafeGate = ARG_GATED_UNSAFE[actualCmd] + if (unsafeGate && unsafeGate(actualArgs)) { + return { safety: 'unsafe', reason: `${actualCmd} invocation mutates state` } + } + + if (ALWAYS_SAFE_COMMANDS.has(actualCmd)) { + return { safety: 'safe', reason: `${actualCmd} is always safe` } + } + + const safeGate = ARG_GATED_SAFE[actualCmd] + if (safeGate && safeGate(actualArgs)) { + return { safety: 'safe', reason: `${actualCmd} read-only invocation` } + } + + return { safety: 'unknown', reason: `${actualCmd} not in allow/deny list` } +} + +/** + * Classify a (possibly compound) Bash command as safe / unsafe / unknown. + */ +export function classifyBashSafety(command: string): SafetyVerdict { + const trimmed = (command ?? '').trim() + if (!trimmed) { + return { safety: 'safe', reason: 'empty command' } + } + + const parts = splitCompound(trimmed) + if (parts.length <= 1) { + return classifyLeaf(trimmed) + } + + const verdicts = parts.map(classifyLeaf) + const hasUnsafe = verdicts.some(v => v.safety === 'unsafe') + const hasUnknown = verdicts.some(v => v.safety === 'unknown') + + if (hasUnsafe) { + return { + safety: 'unsafe', + reason: 'compound command contains an unsafe part', + parts: verdicts, + } + } + if (hasUnknown) { + return { + safety: 'unknown', + reason: 'compound command contains an unknown part', + parts: verdicts, + } + } + return { + safety: 'safe', + reason: 'all parts safe', + parts: verdicts, + } +}