// Package shellsafe is the single source of truth for which shell commands are // read-only — they don't modify filesystem, network, or process state. Both the // permission auto-approve path (internal/permission) and the plan-mode gate // (internal/planmode) classify command membership against these tables, so the // two can't drift (the drift was #5341: a git subcommand auto-approved by // permission yet blocked while planning). Each consumer still layers its own // argument-rigor on top — plan mode is intentionally stricter than auto-approve. package shellsafe import ( "strings" "reasonix/internal/shellparse" ) // ReadOnlyCommands holds single-word commands whose base name alone implies a // read-only operation. The first word of a command (lowercased) is looked up // here. Commands that are read-only only for certain subcommands (e.g. git) are // in ReadOnlyPrefixes. var ReadOnlyCommands = map[string]bool{ "cat": true, "head": true, "tail": true, "less": true, "more": true, "ls": true, "find": true, "locate": true, "which": true, "whereis": true, "type": true, "grep": true, "egrep": true, "fgrep": true, "rg": true, "echo": true, "printf": true, "pwd": true, "cd": true, "whoami": true, "id": true, "uname": true, "hostname": true, "date": true, "printenv": true, "wc": true, "sort": true, "uniq": true, "cut": true, "tr": true, "stat": true, "file": true, "du": true, "df": true, "ps": true, "top": true, "htop": true, "diff": true, "cmp": true, "comm": true, "man": true, "info": true, "help": true, "true": true, "false": true, "test": true, "[": true, "basename": true, "dirname": true, "realpath": true, "readlink": true, } // ReadOnlyPrefixes maps a base command to the set of subcommands (the second // word) that are read-only. A subcommand not listed here is treated as not // read-only (fail-closed): write-capable subcommands (git branch/remote/config, // go build/test, npm install, …) are deliberately absent. var ReadOnlyPrefixes = map[string]map[string]bool{ "git": { "log": true, "status": true, "diff": true, "show": true, "tag": true, "blame": true, "grep": true, "ls-files": true, "ls-tree": true, "rev-parse": true, "rev-list": true, "describe": true, "reflog": true, "shortlog": true, "whatchanged": true, "cherry": true, "cat-file": true, "for-each-ref": true, "name-rev": true, }, "go": { "vet": true, "doc": true, "list": true, "version": true, "env": true, }, "npm": { "ls": true, "list": true, "view": true, "info": true, "outdated": true, "audit": true, }, "cargo": { "check": true, "doc": true, "search": true, }, "docker": { "ps": true, "images": true, "inspect": true, "logs": true, "stats": true, "info": true, "version": true, }, "kubectl": { "get": true, "describe": true, "logs": true, "explain": true, "api-resources": true, "api-versions": true, }, // Version/help probes for common runtimes (the second word is a flag). "node": {"-v": true, "--version": true}, "python": {"--version": true, "-v": true, "-V": true}, "python3": {"--version": true, "-v": true, "-V": true}, } // ContainsShellSyntax reports whether a command uses shell operators or // substitution — chaining/redirection/expansion can smuggle a write past a // read-only base-word check, so any such command is treated as not read-only. func ContainsShellSyntax(cmd string) bool { return shellparse.ContainsShellSyntax(cmd) } // CommandIsReadOnly reports whether the command's base/subcommand is in the // read-only tables, ignoring argument rigor (which each consumer applies). It // returns the base and subcommand so callers can run their own arg checks. // ok is false when the command contains shell syntax or the base/subcommand is // not a known read-only operation. func CommandIsReadOnly(command string) (base, sub string, ok bool) { fields, malformed := shellparse.StaticFields(command) if malformed != "" || len(fields) == 0 { return "", "", false } base = strings.ToLower(fields[0]) if ReadOnlyCommands[base] { return base, "", true } if len(fields) > 1 { if subs, prefixed := ReadOnlyPrefixes[base]; prefixed { sub = strings.ToLower(fields[1]) if subs[sub] { return base, sub, true } } } return "", "", false }