| 1 | // Package shellsafe is the single source of truth for which shell commands are |
| 2 | // read-only — they don't modify filesystem, network, or process state. The |
| 3 | // permission auto-approve path and explicitly read-only runners share these |
| 4 | // tables so their command classification cannot drift. |
| 5 | package shellsafe |
| 6 | |
| 7 | import ( |
| 8 | "strings" |
| 9 | |
| 10 | "mvdan.cc/sh/v3/syntax" |
| 11 | |
| 12 | "reasonix/internal/shellparse" |
| 13 | ) |
| 14 | |
| 15 | // ReadOnlyCommands holds single-word commands whose base name alone implies a |
| 16 | // read-only operation. The first word of a command (lowercased) is looked up |
| 17 | // here. Commands that are read-only only for certain subcommands (e.g. git) are |
| 18 | // in ReadOnlyPrefixes. |
| 19 | var ReadOnlyCommands = map[string]bool{ |
| 20 | "cat": true, "head": true, "tail": true, "less": true, "more": true, |
| 21 | "ls": true, "find": true, "locate": true, "which": true, "whereis": true, "type": true, |
| 22 | "grep": true, "egrep": true, "fgrep": true, "rg": true, |
| 23 | "echo": true, "printf": true, |
| 24 | "pwd": true, "cd": true, "whoami": true, "id": true, "uname": true, "hostname": true, |
| 25 | "date": true, "printenv": true, |
| 26 | "wc": true, "sort": true, "uniq": true, "cut": true, "tr": true, |
| 27 | "stat": true, "file": true, "du": true, "df": true, |
| 28 | "ps": true, "top": true, "htop": true, |
| 29 | "diff": true, "cmp": true, "comm": true, |
| 30 | "man": true, "info": true, "help": true, |
| 31 | "true": true, "false": true, "test": true, "[": true, |
| 32 | "basename": true, "dirname": true, "realpath": true, "readlink": true, |
| 33 | // PowerShell inspection cmdlets. Keep this list intentionally narrow: only |
| 34 | // cmdlets whose verb is intrinsically observational belong here. The parser |
| 35 | // still rejects pipelines, substitutions, redirections, and command chains. |
| 36 | "get-childitem": true, "get-content": true, "get-item": true, |
| 37 | "get-location": true, "get-process": true, "get-command": true, |
| 38 | "get-nettcpconnection": true, |
| 39 | "resolve-path": true, "select-string": true, "measure-object": true, |
| 40 | "compare-object": true, |
| 41 | } |
| 42 | |
| 43 | // workspaceNonMutatingCommands holds commands that do not write workspace |
| 44 | // state but are not safe to auto-allow as permission-layer readers. Keep this |
| 45 | // separate from ReadOnlyCommands: Test-NetConnection performs network I/O and |
| 46 | // must still pass through the user's permission policy even though Delivery |
| 47 | // does not need to serialize it behind the workspace writer. |
| 48 | var workspaceNonMutatingCommands = map[string]bool{ |
| 49 | "test-netconnection": true, |
| 50 | } |
| 51 | |
| 52 | // ReadOnlyPrefixes maps a base command to the set of subcommands (the second |
| 53 | // word) that are read-only. A subcommand not listed here is treated as not |
| 54 | // read-only (fail-closed): write-capable subcommands (git branch/remote/config, |
| 55 | // go build/test, npm install, …) are deliberately absent. |
| 56 | var ReadOnlyPrefixes = map[string]map[string]bool{ |
| 57 | "git": { |
| 58 | "log": true, "status": true, "diff": true, "show": true, |
| 59 | "tag": true, |
| 60 | "blame": true, "grep": true, "ls-files": true, "ls-tree": true, |
| 61 | "rev-parse": true, "rev-list": true, "describe": true, "reflog": true, |
| 62 | "shortlog": true, "whatchanged": true, "cherry": true, |
| 63 | "cat-file": true, "for-each-ref": true, "name-rev": true, |
| 64 | }, |
| 65 | "go": { |
| 66 | "vet": true, "doc": true, "list": true, |
| 67 | "version": true, "env": true, |
| 68 | }, |
| 69 | "npm": { |
| 70 | "ls": true, "list": true, "view": true, "info": true, |
| 71 | "outdated": true, "audit": true, |
| 72 | }, |
| 73 | "cargo": { |
| 74 | "check": true, "doc": true, "search": true, |
| 75 | }, |
| 76 | "docker": { |
| 77 | "ps": true, "images": true, "inspect": true, "logs": true, |
| 78 | "stats": true, "info": true, "version": true, |
| 79 | }, |
| 80 | "kubectl": { |
| 81 | "get": true, "describe": true, "logs": true, "explain": true, |
| 82 | "api-resources": true, "api-versions": true, |
| 83 | }, |
| 84 | // Version/help probes for common runtimes (the second word is a flag). |
| 85 | "node": {"-v": true, "--version": true}, |
| 86 | "python": {"--version": true, "-v": true, "-V": true}, |
| 87 | "python3": {"--version": true, "-v": true, "-V": true}, |
| 88 | } |
| 89 | |
| 90 | // ContainsShellSyntax reports whether a command uses shell operators or |
| 91 | // substitution — chaining/redirection/expansion can smuggle a write past a |
| 92 | // read-only base-word check, so any such command is treated as not read-only. |
| 93 | func ContainsShellSyntax(cmd string) bool { |
| 94 | return shellparse.ContainsShellSyntax(cmd) |
| 95 | } |
| 96 | |
| 97 | // CommandIsReadOnly reports whether the command's base/subcommand is in the |
| 98 | // read-only tables, ignoring argument rigor (which each consumer applies). It |
| 99 | // returns the base and subcommand so callers can run their own arg checks. |
| 100 | // ok is false when the command contains shell syntax or the base/subcommand is |
| 101 | // not a known read-only operation. |
| 102 | func CommandIsReadOnly(command string) (base, sub string, ok bool) { |
| 103 | base, sub, _, ok = ClassifyReadOnlyCommand(command) |
| 104 | return base, sub, ok |
| 105 | } |
| 106 | |
| 107 | // ClassifyReadOnlyCommand returns the resolved argument fields as well as the |
| 108 | // command classification. Dynamic fields are opaque placeholders and are |
| 109 | // returned only for the narrowly verified substitution shape above. |
| 110 | func ClassifyReadOnlyCommand(command string) (base, sub string, fields []string, ok bool) { |
| 111 | fields, malformed := shellparse.StaticFields(command) |
| 112 | if malformed != "" { |
| 113 | var dynamic bool |
| 114 | fields, dynamic, ok = resolvedReadOnlyFields(command, false) |
| 115 | if !ok || !dynamic { |
| 116 | return "", "", nil, false |
| 117 | } |
| 118 | } |
| 119 | if len(fields) == 0 { |
| 120 | return "", "", nil, false |
| 121 | } |
| 122 | base = strings.ToLower(fields[0]) |
| 123 | if ReadOnlyCommands[base] { |
| 124 | if hasResolvedSubstitution(fields) && !substitutionSafeCommands[base] { |
| 125 | return "", "", nil, false |
| 126 | } |
| 127 | return base, "", fields, true |
| 128 | } |
| 129 | if len(fields) > 1 { |
| 130 | if subs, prefixed := ReadOnlyPrefixes[base]; prefixed { |
| 131 | sub = strings.ToLower(fields[1]) |
| 132 | if subs[sub] { |
| 133 | return base, sub, fields, true |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | return "", "", nil, false |
| 138 | } |
| 139 | |
| 140 | const resolvedSubstitutionPlaceholder = "__reasonix_read_only_substitution__" |
| 141 | |
| 142 | var substitutionSafeCommands = map[string]bool{ |
| 143 | "cat": true, "head": true, "tail": true, "ls": true, |
| 144 | "grep": true, "egrep": true, "fgrep": true, "rg": true, |
| 145 | "echo": true, "printf": true, "pwd": true, "whoami": true, |
| 146 | "id": true, "uname": true, "hostname": true, "wc": true, |
| 147 | "stat": true, "file": true, "du": true, "df": true, |
| 148 | "cmp": true, "comm": true, "true": true, "false": true, |
| 149 | "test": true, "[": true, "basename": true, "dirname": true, |
| 150 | "realpath": true, "readlink": true, |
| 151 | } |
| 152 | |
| 153 | func hasResolvedSubstitution(fields []string) bool { |
| 154 | for _, field := range fields { |
| 155 | if strings.Contains(field, resolvedSubstitutionPlaceholder) { |
| 156 | return true |
| 157 | } |
| 158 | } |
| 159 | return false |
| 160 | } |
| 161 | |
| 162 | // resolvedReadOnlyFields accepts one narrow dynamic shape: a command from the |
| 163 | // read-only table with a double-quoted command substitution whose nested |
| 164 | // command is itself a single, static, argument-safe read-only command. It never |
| 165 | // evaluates output. Unquoted substitutions, parameters, arithmetic, process |
| 166 | // substitutions, redirects, assignments, chains, and background jobs remain |
| 167 | // fail-closed. |
| 168 | func resolvedReadOnlyFields(command string, nested bool) ([]string, bool, bool) { |
| 169 | file, err := shellparse.ParseBash(command) |
| 170 | if err != nil || len(file.Stmts) != 1 { |
| 171 | return nil, false, false |
| 172 | } |
| 173 | return resolvedReadOnlyStmt(file.Stmts[0], nested) |
| 174 | } |
| 175 | |
| 176 | func resolvedReadOnlyStmt(stmt *syntax.Stmt, nested bool) ([]string, bool, bool) { |
| 177 | if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 { |
| 178 | return nil, false, false |
| 179 | } |
| 180 | call, ok := stmt.Cmd.(*syntax.CallExpr) |
| 181 | if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 { |
| 182 | return nil, false, false |
| 183 | } |
| 184 | fields := make([]string, 0, len(call.Args)) |
| 185 | dynamic := false |
| 186 | for _, word := range call.Args { |
| 187 | field, wordDynamic, ok := resolvedReadOnlyWord(word) |
| 188 | if !ok { |
| 189 | return nil, false, false |
| 190 | } |
| 191 | fields = append(fields, field) |
| 192 | dynamic = dynamic || wordDynamic |
| 193 | } |
| 194 | base, sub, tableOK := readOnlyFields(fields) |
| 195 | if !tableOK || (nested && (!substitutionSafeCommands[base] || !nestedReadOnlyArgsSafe(base, sub, fields))) { |
| 196 | return nil, false, false |
| 197 | } |
| 198 | return fields, dynamic, true |
| 199 | } |
| 200 | |
| 201 | func resolvedReadOnlyWord(word *syntax.Word) (string, bool, bool) { |
| 202 | if static, ok := shellparse.StaticWord(word); ok { |
| 203 | return static, false, true |
| 204 | } |
| 205 | if word == nil { |
| 206 | return "", false, false |
| 207 | } |
| 208 | var out strings.Builder |
| 209 | dynamic := false |
| 210 | for _, part := range word.Parts { |
| 211 | value, partDynamic, ok := resolvedReadOnlyWordPart(part, false) |
| 212 | if !ok { |
| 213 | return "", false, false |
| 214 | } |
| 215 | out.WriteString(value) |
| 216 | dynamic = dynamic || partDynamic |
| 217 | } |
| 218 | return out.String(), dynamic, true |
| 219 | } |
| 220 | |
| 221 | func resolvedReadOnlyWordPart(part syntax.WordPart, doubleQuoted bool) (string, bool, bool) { |
| 222 | switch value := part.(type) { |
| 223 | case *syntax.Lit: |
| 224 | return value.Value, false, true |
| 225 | case *syntax.SglQuoted: |
| 226 | return value.Value, false, true |
| 227 | case *syntax.DblQuoted: |
| 228 | var out strings.Builder |
| 229 | dynamic := false |
| 230 | for _, nested := range value.Parts { |
| 231 | text, partDynamic, ok := resolvedReadOnlyWordPart(nested, true) |
| 232 | if !ok { |
| 233 | return "", false, false |
| 234 | } |
| 235 | out.WriteString(text) |
| 236 | dynamic = dynamic || partDynamic |
| 237 | } |
| 238 | return out.String(), dynamic, true |
| 239 | case *syntax.CmdSubst: |
| 240 | if !doubleQuoted || value.TempFile || value.ReplyVar || len(value.Stmts) != 1 { |
| 241 | return "", false, false |
| 242 | } |
| 243 | if _, _, ok := resolvedReadOnlyStmt(value.Stmts[0], true); !ok { |
| 244 | return "", false, false |
| 245 | } |
| 246 | return resolvedSubstitutionPlaceholder, true, true |
| 247 | default: |
| 248 | return "", false, false |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func readOnlyFields(fields []string) (base, sub string, ok bool) { |
| 253 | if len(fields) == 0 { |
| 254 | return "", "", false |
| 255 | } |
| 256 | base = strings.ToLower(fields[0]) |
| 257 | if ReadOnlyCommands[base] { |
| 258 | return base, "", true |
| 259 | } |
| 260 | if len(fields) > 1 { |
| 261 | sub = strings.ToLower(fields[1]) |
| 262 | if ReadOnlyPrefixes[base][sub] { |
| 263 | return base, sub, true |
| 264 | } |
| 265 | } |
| 266 | return "", "", false |
| 267 | } |
| 268 | |
| 269 | func nestedReadOnlyArgsSafe(base, sub string, fields []string) bool { |
| 270 | args := fields[1:] |
| 271 | if sub != "" && len(args) > 0 { |
| 272 | args = args[1:] |
| 273 | } |
| 274 | for _, arg := range args { |
| 275 | switch { |
| 276 | case base == "find" && (arg == "-exec" || arg == "-execdir" || arg == "-delete" || arg == "-ok" || arg == "-okdir" || arg == "-fls" || arg == "-fprint" || arg == "-fprint0" || arg == "-fprintf"): |
| 277 | return false |
| 278 | case base == "sed" && (strings.HasPrefix(arg, "-i") || strings.HasPrefix(arg, "--in-place")): |
| 279 | return false |
| 280 | case base == "sort" && (strings.HasPrefix(arg, "-o") || arg == "--output" || strings.HasPrefix(arg, "--output=")): |
| 281 | return false |
| 282 | case base == "git" && (sub == "diff" || sub == "show" || sub == "log") && (arg == "--output" || strings.HasPrefix(arg, "--output=")): |
| 283 | return false |
| 284 | case base == "go" && sub == "env" && (arg == "-w" || arg == "-u"): |
| 285 | return false |
| 286 | } |
| 287 | } |
| 288 | return base != "git" || sub != "tag" || len(args) == 0 || args[0] == "-l" || args[0] == "--list" |
| 289 | } |
| 290 | |
| 291 | // CommandIsWorkspaceNonMutating reports commands that Delivery can execute |
| 292 | // without acquiring the workspace writer. Permission-safe readers are a |
| 293 | // subset; network probes live only in workspaceNonMutatingCommands so this |
| 294 | // classification cannot silently widen approval or read-only subagent access. |
| 295 | func CommandIsWorkspaceNonMutating(command string) (base, sub string, ok bool) { |
| 296 | base, sub, _, ok = ClassifyWorkspaceNonMutatingCommand(command) |
| 297 | return base, sub, ok |
| 298 | } |
| 299 | |
| 300 | // ClassifyWorkspaceNonMutatingCommand is the field-carrying form used by |
| 301 | // Delivery mutation accounting. |
| 302 | func ClassifyWorkspaceNonMutatingCommand(command string) (base, sub string, fields []string, ok bool) { |
| 303 | if base, sub, fields, ok = ClassifyReadOnlyCommand(command); ok { |
| 304 | return base, sub, fields, true |
| 305 | } |
| 306 | fields, malformed := shellparse.StaticFields(command) |
| 307 | if malformed != "" || len(fields) == 0 { |
| 308 | return "", "", nil, false |
| 309 | } |
| 310 | base = strings.ToLower(fields[0]) |
| 311 | if workspaceNonMutatingCommands[base] { |
| 312 | return base, "", fields, true |
| 313 | } |
| 314 | return "", "", nil, false |
| 315 | } |
| 316 |