返回 DeepSeek-Reasonix
shellsafe.go
根目录 / internal / shellsafe / shellsafe.go
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/doc are deliberately absent: cargo runs the crate's build.rs
75 // (arbitrary code) even for them — classifyCargo already bills both as
76 // code-executing writers, and this legacy table must not disagree.
77 "search": true,
78 },
79 "docker": {
80 "ps": true, "images": true, "inspect": true, "logs": true,
81 "stats": true, "info": true, "version": true,
82 },
83 "kubectl": {
84 "get": true, "describe": true, "logs": true, "explain": true,
85 "api-resources": true, "api-versions": true,
86 },
87 // Version/help probes for common runtimes (the second word is a flag).
88 "node": {"-v": true, "--version": true},
89 "python": {"--version": true, "-v": true, "-V": true},
90 "python3": {"--version": true, "-v": true, "-V": true},
91 }
92
93 // ContainsShellSyntax reports whether a command uses shell operators or
94 // substitution — chaining/redirection/expansion can smuggle a write past a
95 // read-only base-word check, so any such command is treated as not read-only.
96 func ContainsShellSyntax(cmd string) bool {
97 return shellparse.ContainsShellSyntax(cmd)
98 }
99
100 // CommandIsReadOnly is the legacy coarse read-only adapter.
101 // Deprecated: production policy must use ClassifyBash and project its axis.
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 // Deprecated: retained for compatibility and narrow substitution validation.
111 func ClassifyReadOnlyCommand(command string) (base, sub string, fields []string, ok bool) {
112 fields, malformed := shellparse.StaticFields(command)
113 if malformed != "" {
114 var dynamic bool
115 fields, dynamic, ok = resolvedReadOnlyFields(command, false)
116 if !ok || !dynamic {
117 return "", "", nil, false
118 }
119 }
120 if len(fields) == 0 {
121 return "", "", nil, false
122 }
123 base = strings.ToLower(fields[0])
124 if readOnlyCommands[base] {
125 if hasResolvedSubstitution(fields) && !substitutionSafeCommands[base] {
126 return "", "", nil, false
127 }
128 return base, "", fields, true
129 }
130 if len(fields) > 1 {
131 if subs, prefixed := readOnlyPrefixes[base]; prefixed {
132 sub = strings.ToLower(fields[1])
133 if subs[sub] {
134 return base, sub, fields, true
135 }
136 }
137 }
138 return "", "", nil, false
139 }
140
141 const resolvedSubstitutionPlaceholder = "__reasonix_read_only_substitution__"
142
143 var substitutionSafeCommands = map[string]bool{
144 "cat": true, "head": true, "tail": true, "ls": true,
145 "grep": true, "egrep": true, "fgrep": true, "rg": true,
146 "echo": true, "printf": true, "pwd": true, "whoami": true,
147 "id": true, "uname": true, "hostname": true, "wc": true,
148 "stat": true, "file": true, "du": true, "df": true,
149 "cmp": true, "comm": true, "true": true, "false": true,
150 "test": true, "[": true, "basename": true, "dirname": true,
151 "realpath": true, "readlink": true,
152 }
153
154 func hasResolvedSubstitution(fields []string) bool {
155 for _, field := range fields {
156 if strings.Contains(field, resolvedSubstitutionPlaceholder) {
157 return true
158 }
159 }
160 return false
161 }
162
163 // resolvedReadOnlyFields accepts one narrow dynamic shape: a command from the
164 // read-only table with a double-quoted command substitution whose nested
165 // command is itself a single, static, argument-safe read-only command. It never
166 // evaluates output. Unquoted substitutions, parameters, arithmetic, process
167 // substitutions, redirects, assignments, chains, and background jobs remain
168 // fail-closed.
169 func resolvedReadOnlyFields(command string, nested bool) ([]string, bool, bool) {
170 file, err := shellparse.ParseBash(command)
171 if err != nil || len(file.Stmts) != 1 {
172 return nil, false, false
173 }
174 return resolvedReadOnlyStmt(file.Stmts[0], nested)
175 }
176
177 func resolvedReadOnlyStmt(stmt *syntax.Stmt, nested bool) ([]string, bool, bool) {
178 if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
179 return nil, false, false
180 }
181 call, ok := stmt.Cmd.(*syntax.CallExpr)
182 if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
183 return nil, false, false
184 }
185 fields := make([]string, 0, len(call.Args))
186 dynamic := false
187 for _, word := range call.Args {
188 field, wordDynamic, ok := resolvedReadOnlyWord(word)
189 if !ok {
190 return nil, false, false
191 }
192 fields = append(fields, field)
193 dynamic = dynamic || wordDynamic
194 }
195 base, sub, tableOK := readOnlyFields(fields)
196 if !tableOK || (nested && (!substitutionSafeCommands[base] || !nestedReadOnlyArgsSafe(base, sub, fields))) {
197 return nil, false, false
198 }
199 return fields, dynamic, true
200 }
201
202 func resolvedReadOnlyWord(word *syntax.Word) (string, bool, bool) {
203 if static, ok := shellparse.StaticWord(word); ok {
204 return static, false, true
205 }
206 if word == nil {
207 return "", false, false
208 }
209 var out strings.Builder
210 dynamic := false
211 for _, part := range word.Parts {
212 value, partDynamic, ok := resolvedReadOnlyWordPart(part, false)
213 if !ok {
214 return "", false, false
215 }
216 out.WriteString(value)
217 dynamic = dynamic || partDynamic
218 }
219 return out.String(), dynamic, true
220 }
221
222 func resolvedReadOnlyWordPart(part syntax.WordPart, doubleQuoted bool) (string, bool, bool) {
223 switch value := part.(type) {
224 case *syntax.Lit:
225 return value.Value, false, true
226 case *syntax.SglQuoted:
227 return value.Value, false, true
228 case *syntax.DblQuoted:
229 var out strings.Builder
230 dynamic := false
231 for _, nested := range value.Parts {
232 text, partDynamic, ok := resolvedReadOnlyWordPart(nested, true)
233 if !ok {
234 return "", false, false
235 }
236 out.WriteString(text)
237 dynamic = dynamic || partDynamic
238 }
239 return out.String(), dynamic, true
240 case *syntax.CmdSubst:
241 if !doubleQuoted || value.TempFile || value.ReplyVar || len(value.Stmts) != 1 {
242 return "", false, false
243 }
244 if _, _, ok := resolvedReadOnlyStmt(value.Stmts[0], true); !ok {
245 return "", false, false
246 }
247 return resolvedSubstitutionPlaceholder, true, true
248 default:
249 return "", false, false
250 }
251 }
252
253 func readOnlyFields(fields []string) (base, sub string, ok bool) {
254 if len(fields) == 0 {
255 return "", "", false
256 }
257 base = strings.ToLower(fields[0])
258 if readOnlyCommands[base] {
259 return base, "", true
260 }
261 if len(fields) > 1 {
262 sub = strings.ToLower(fields[1])
263 if readOnlyPrefixes[base][sub] {
264 return base, sub, true
265 }
266 }
267 return "", "", false
268 }
269
270 func nestedReadOnlyArgsSafe(base, sub string, fields []string) bool {
271 args := fields[1:]
272 if sub != "" && len(args) > 0 {
273 args = args[1:]
274 }
275 for _, arg := range args {
276 switch {
277 case base == "find" && (arg == "-exec" || arg == "-execdir" || arg == "-delete" || arg == "-ok" || arg == "-okdir" || arg == "-fls" || arg == "-fprint" || arg == "-fprint0" || arg == "-fprintf"):
278 return false
279 case base == "sed" && (strings.HasPrefix(arg, "-i") || strings.HasPrefix(arg, "--in-place")):
280 return false
281 case base == "sort" && (strings.HasPrefix(arg, "-o") || arg == "--output" || strings.HasPrefix(arg, "--output=")):
282 return false
283 case base == "git" && (sub == "diff" || sub == "show" || sub == "log") && (arg == "--output" || strings.HasPrefix(arg, "--output=")):
284 return false
285 case base == "go" && sub == "env" && (arg == "-w" || arg == "-u"):
286 return false
287 }
288 }
289 return base != "git" || sub != "tag" || len(args) == 0 || args[0] == "-l" || args[0] == "--list"
290 }
291
292 // CommandIsWorkspaceNonMutating reports commands that Delivery can execute
293 // without acquiring the workspace writer. Permission-safe readers are a
294 // subset; network probes live only in workspaceNonMutatingCommands so this
295 // classification cannot silently widen approval or read-only subagent access.
296 // Deprecated: production policy consumers must use ClassifyBash.
297 func CommandIsWorkspaceNonMutating(command string) (base, sub string, ok bool) {
298 base, sub, _, ok = ClassifyWorkspaceNonMutatingCommand(command)
299 return base, sub, ok
300 }
301
302 // ClassifyWorkspaceNonMutatingCommand is the field-carrying form used by
303 // Delivery mutation accounting.
304 // Deprecated: retained as a compatibility adapter.
305 func ClassifyWorkspaceNonMutatingCommand(command string) (base, sub string, fields []string, ok bool) {
306 if base, sub, fields, ok = ClassifyReadOnlyCommand(command); ok {
307 return base, sub, fields, true
308 }
309 fields, malformed := shellparse.StaticFields(command)
310 if malformed != "" || len(fields) == 0 {
311 return "", "", nil, false
312 }
313 base = strings.ToLower(fields[0])
314 if workspaceNonMutatingCommands[base] {
315 return base, "", fields, true
316 }
317 return "", "", nil, false
318 }
319
319 lines GO