返回 DeepSeek-Reasonix
bash_approval.go
根目录 / internal / permission / bash_approval.go
1 package permission
2
3 import (
4 "strings"
5
6 "reasonix/internal/shellparse"
7 )
8
9 type bashApprovalClass uint8
10
11 const (
12 bashApprovalReusable bashApprovalClass = iota
13 bashApprovalExactOnly
14 bashApprovalRequireHuman
15 )
16
17 // BashSubjectRequiresExplicitApproval reports whether subject can execute a
18 // nested or indirect command and therefore needs a human in Ask/Auto. Exact
19 // command rules are handled separately by Policy before this classification.
20 func BashSubjectRequiresExplicitApproval(subject string) bool {
21 return classifyBashApproval(subject) == bashApprovalRequireHuman
22 }
23
24 func bashSubjectRequiresExactRule(subject string) bool {
25 return classifyBashApproval(subject) != bashApprovalReusable
26 }
27
28 func classifyBashApproval(subject string) bashApprovalClass {
29 if strings.TrimSpace(subject) == "" {
30 return bashApprovalReusable
31 }
32 segments, _, ok := shellparse.SplitTopLevel(subject)
33 if !ok {
34 return classifyBashSegmentApproval(subject)
35 }
36 if len(segments) == 0 {
37 return bashApprovalRequireHuman
38 }
39 class := bashApprovalReusable
40 for _, segment := range segments {
41 segmentClass := classifyBashSegmentApproval(segment)
42 if segmentClass > class {
43 class = segmentClass
44 }
45 if class == bashApprovalRequireHuman {
46 break
47 }
48 }
49 return class
50 }
51
52 func classifyBashSegmentApproval(subject string) bashApprovalClass {
53 if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok {
54 subject = normalized
55 }
56 features, ok := shellparse.AnalyzeApprovalFeatures(subject)
57 if !ok || features.NestedExecution || features.DynamicCommandName {
58 return bashApprovalRequireHuman
59 }
60 if len(features.CommandPrefix) > 0 && isIndirectExecution(features.CommandPrefix) {
61 return bashApprovalRequireHuman
62 }
63 if features.Expansion || features.Assignment || features.Redirection ||
64 shellparse.ContainsUnquotedGlob(subject) || hasEnvWrapperAssignment(features.CommandPrefix) {
65 return bashApprovalExactOnly
66 }
67 return bashApprovalReusable
68 }
69
70 func isIndirectExecution(fields []string) bool {
71 if len(fields) == 0 {
72 return true
73 }
74 base := executableBase(fields[0])
75 args := fields[1:]
76
77 switch base {
78 case "eval", "source", ".", "xargs":
79 return true
80 case "env":
81 for len(args) > 0 && isEnvironmentAssignment(args[0]) {
82 args = args[1:]
83 }
84 if len(args) == 0 || strings.HasPrefix(args[0], "-") {
85 return true
86 }
87 return isIndirectExecution(args)
88 case "builtin", "command", "exec", "nohup", "sudo":
89 if len(args) == 0 || strings.HasPrefix(args[0], "-") {
90 return true
91 }
92 return isIndirectExecution(args)
93 case "bash", "dash", "fish", "ksh", "sh", "zsh":
94 return hasShellCommandFlag(args)
95 case "powershell", "pwsh":
96 return hasAnyFoldedArg(args, "-c", "-command", "-e", "-enc", "-encodedcommand")
97 case "cmd":
98 return hasAnyFoldedArg(args, "/c", "/k")
99 case "node", "bun":
100 return hasAnyFoldedArg(args, "-e", "--eval", "-p", "--print")
101 case "deno":
102 return hasAnyFoldedArg(args, "eval")
103 case "python", "python3", "py", "pypy", "pypy3":
104 return hasAnyFoldedArg(args, "-c")
105 case "perl", "ruby", "lua", "luajit", "r", "rscript", "osascript":
106 return hasAnyFoldedArg(args, "-e")
107 case "php":
108 return hasAnyFoldedArg(args, "-r")
109 case "find":
110 return hasAnyFoldedArg(args, "-exec", "-execdir", "-ok", "-okdir")
111 case "awk", "gawk", "mawk", "nawk":
112 // awk has no inline-code flag: an inline program (no -f/--file) can
113 // run shell commands (system(), "| getline"), so it is treated like
114 // python -c; awk -f script.awk stays reusable like python script.py.
115 return !awkUsesOnlyScriptFiles(args)
116 default:
117 return false
118 }
119 }
120
121 func awkUsesOnlyScriptFiles(args []string) bool {
122 hasFile := false
123 for i := 0; i < len(args); i++ {
124 arg := args[i]
125 switch {
126 case arg == "-f" || arg == "--file":
127 if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
128 return false
129 }
130 hasFile = true
131 i++
132 case strings.HasPrefix(arg, "-f") && len(arg) > 2:
133 hasFile = true
134 case strings.HasPrefix(arg, "--file="):
135 if strings.TrimPrefix(arg, "--file=") == "" {
136 return false
137 }
138 hasFile = true
139 case arg == "-E" || arg == "--exec":
140 return i+1 < len(args)
141 case strings.HasPrefix(arg, "-E") && len(arg) > 2:
142 return true
143 case strings.HasPrefix(arg, "--exec="):
144 return strings.TrimPrefix(arg, "--exec=") != ""
145 case arg == "-e" || arg == "--source",
146 strings.HasPrefix(arg, "-e") && len(arg) > 2,
147 strings.HasPrefix(arg, "--source="):
148 return false
149 }
150 }
151 return hasFile
152 }
153
154 func hasEnvWrapperAssignment(fields []string) bool {
155 if len(fields) < 2 || executableBase(fields[0]) != "env" {
156 return false
157 }
158 for _, arg := range fields[1:] {
159 if isEnvironmentAssignment(arg) {
160 return true
161 }
162 if !strings.HasPrefix(arg, "-") {
163 return false
164 }
165 }
166 return false
167 }
168
169 func executableBase(command string) string {
170 if i := strings.LastIndexAny(command, `/\\`); i >= 0 {
171 command = command[i+1:]
172 }
173 command = strings.ToLower(command)
174 return strings.TrimSuffix(command, ".exe")
175 }
176
177 func hasShellCommandFlag(args []string) bool {
178 for _, arg := range args {
179 lower := strings.ToLower(arg)
180 if lower == "--" {
181 return false
182 }
183 if lower == "--command" {
184 return true
185 }
186 if strings.HasPrefix(lower, "-") && !strings.HasPrefix(lower, "--") && strings.Contains(lower[1:], "c") {
187 return true
188 }
189 }
190 return false
191 }
192
193 func hasAnyFoldedArg(args []string, candidates ...string) bool {
194 for _, arg := range args {
195 lower := strings.ToLower(arg)
196 for _, candidate := range candidates {
197 candidate = strings.ToLower(candidate)
198 if lower == candidate {
199 return true
200 }
201 if strings.HasPrefix(candidate, "--") && (strings.HasPrefix(lower, candidate+"=") || strings.HasPrefix(lower, candidate+":")) {
202 return true
203 }
204 if strings.HasPrefix(candidate, "-") && !strings.HasPrefix(candidate, "--") && len(candidate) == 2 && strings.HasPrefix(lower, candidate) && !strings.HasPrefix(lower, "--") {
205 return true
206 }
207 if strings.HasPrefix(candidate, "/") && len(candidate) == 2 && strings.HasPrefix(lower, candidate) {
208 return true
209 }
210 if len(candidate) > 2 && strings.HasPrefix(candidate, "-") && strings.HasPrefix(lower, candidate+":") {
211 return true
212 }
213 }
214 }
215 return false
216 }
217
218 func isEnvironmentAssignment(arg string) bool {
219 name, _, ok := strings.Cut(arg, "=")
220 if !ok || name == "" {
221 return false
222 }
223 for i, r := range name {
224 letter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
225 digit := i > 0 && r >= '0' && r <= '9'
226 if !letter && !digit && r != '_' {
227 return false
228 }
229 }
230 return true
231 }
232
232 lines GO