返回 DeepSeek-Reasonix
bash_decompose_test.go
根目录 / internal / permission / bash_decompose_test.go
1 package permission
2
3 import (
4 "reflect"
5 "testing"
6 )
7
8 func TestDecomposeBashCommand(t *testing.T) {
9 tests := []struct {
10 name string
11 in string
12 want []string
13 }{
14 {
15 name: "atomic command returns nil",
16 in: "git status",
17 want: nil,
18 },
19 {
20 name: "atomic with redirect (has shell syntax but no operator) returns nil",
21 in: "grep -r TODO . 2>/dev/null",
22 want: nil,
23 },
24 {
25 name: "&& chain",
26 in: `git add . && git commit -m "wip" && git push`,
27 want: []string{"git add .", `git commit -m "wip"`, "git push"},
28 },
29 {
30 name: "|| fallback",
31 in: `sudo chmod 644 /etc/foo || echo "chmod failed"`,
32 want: []string{"sudo chmod 644 /etc/foo", `echo "chmod failed"`},
33 },
34 {
35 name: "pipe",
36 in: "git log --oneline | head -20",
37 want: []string{"git log --oneline", "head -20"},
38 },
39 {
40 name: "semicolon",
41 in: "cd /tmp; ls -la",
42 want: []string{"cd /tmp", "ls -la"},
43 },
44 {
45 name: "mixed compound",
46 in: `sudo chmod 644 /etc/ssh/foo 2>/dev/null || echo "sudo not available, trying alternative" && ssh -T git@github.com 2>&1`,
47 want: []string{
48 "sudo chmod 644 /etc/ssh/foo 2>/dev/null",
49 `echo "sudo not available, trying alternative"`,
50 "ssh -T git@github.com 2>&1",
51 },
52 },
53 {
54 name: "operator inside single quotes stays intact",
55 in: `echo 'a && b' && ls`,
56 want: []string{`echo 'a && b'`, "ls"},
57 },
58 {
59 name: "operator inside double quotes stays intact",
60 in: `echo "x | y" | wc -l`,
61 want: []string{`echo "x | y"`, "wc -l"},
62 },
63 {
64 name: "operator inside $(...) stays intact",
65 in: `echo $(git rev-parse HEAD; date) && ls`,
66 want: []string{`echo $(git rev-parse HEAD; date)`, "ls"},
67 },
68 {
69 name: "operator inside backticks stays intact",
70 in: "echo `git status; ls` && date",
71 want: []string{"echo `git status; ls`", "date"},
72 },
73 {
74 name: "2>&1 redirection is not a splitter",
75 in: "go test ./... 2>&1 | tee log",
76 want: []string{"go test ./... 2>&1", "tee log"},
77 },
78 {
79 name: "&>/dev/null redirection is not a splitter",
80 in: "git log &>/dev/null | head -20",
81 want: []string{"git log &>/dev/null", "head -20"},
82 },
83 {
84 name: "leading &>/dev/null redirection is not malformed",
85 in: "&>/dev/null git log | head -20",
86 want: []string{"&>/dev/null git log", "head -20"},
87 },
88 {
89 name: "empty tail after trailing operator is dropped",
90 in: "ls -la;",
91 want: nil, // only one non-empty segment after split
92 },
93 {
94 name: "unclosed quote returns nil (falls back to exact)",
95 in: `echo "hello && ls`,
96 want: nil,
97 },
98 {
99 name: "unclosed $(...) returns nil",
100 in: "echo $(git status && ls",
101 want: nil,
102 },
103 {
104 name: "newline splits",
105 in: "cd /tmp\nls",
106 want: []string{"cd /tmp", "ls"},
107 },
108 {
109 name: "heredoc bails to nil (known out-of-scope)",
110 in: "cat <<EOF && ls\nline1\nEOF",
111 want: nil,
112 },
113 {
114 name: "leading && is malformed, returns nil",
115 in: "&& ls",
116 want: nil,
117 },
118 {
119 name: "leading || is malformed, returns nil",
120 in: "|| echo hi",
121 want: nil,
122 },
123 {
124 name: "leading ; is malformed, returns nil",
125 in: "; ls",
126 want: nil,
127 },
128 {
129 name: "leading | is malformed, returns nil",
130 in: "| grep foo",
131 want: nil,
132 },
133 {
134 name: "process substitution <(cmd) is opaque, operators inside don't split",
135 in: "diff <(git log -1 | head) <(git show HEAD | head) && ls",
136 want: []string{
137 "diff <(git log -1 | head) <(git show HEAD | head)",
138 "ls",
139 },
140 },
141 {
142 name: "process substitution >(cmd) is opaque",
143 in: "tee >(gzip | tar) && date",
144 want: []string{"tee >(gzip | tar)", "date"},
145 },
146 {
147 name: "single < is redirect, stays with segment",
148 in: "sort < input.txt && ls",
149 want: []string{"sort < input.txt", "ls"},
150 },
151 }
152 for _, tt := range tests {
153 t.Run(tt.name, func(t *testing.T) {
154 got := DecomposeBashCommand(tt.in)
155 if !reflect.DeepEqual(got, tt.want) {
156 t.Errorf("DecomposeBashCommand(%q)\n got: %#v\n want: %#v", tt.in, got, tt.want)
157 }
158 })
159 }
160 }
161
162 func TestPolicyDecideCompoundBash(t *testing.T) {
163 // Simulate a user who has approved `git add`, `git commit`, `git push`
164 // atomically at some earlier point — either via config or via the
165 // prefix-rule save path that already exists.
166 p := New("ask", []string{
167 "Bash(git add:*)",
168 "Bash(git commit:*)",
169 "Bash(git push:*)",
170 "Bash(go test:*)",
171 "Bash(sudo chmod:*)",
172 }, nil, []string{
173 "Bash(rm -rf*)",
174 })
175
176 cases := []struct {
177 name string
178 subject string
179 want Decision
180 }{
181 {
182 name: "compound of atomic-allowed segments passes",
183 subject: `git add . && git commit -m "wip" && git push`,
184 want: Allow,
185 },
186 {
187 name: "one uncovered segment turns into Ask",
188 subject: `git add . && git commit -m "wip" && git push && npm publish`,
189 want: Ask,
190 },
191 {
192 name: "deny in any segment wins",
193 subject: `git add . && rm -rf /tmp/scratch`,
194 want: Deny,
195 },
196 {
197 name: "read-only segments auto-allow without a rule",
198 subject: `echo starting && git add . && ls -la`,
199 want: Allow,
200 },
201 {
202 name: "compound with || also passes when segments have no redirects",
203 subject: `sudo chmod 644 /etc/foo || echo "chmod failed"`,
204 // sudo chmod ... → matches Bash(sudo chmod:*)
205 // echo "..." → readonly builtin
206 want: Allow,
207 },
208 {
209 name: "segment with dev null redirect still matches prefix rule",
210 subject: `sudo chmod 644 /etc/foo 2>/dev/null || echo "chmod failed"`,
211 want: Allow,
212 },
213 {
214 name: "segment with file redirect still misses prefix rule",
215 subject: `sudo chmod 644 /etc/foo > chmod.log || echo "chmod failed"`,
216 want: Ask,
217 },
218 {
219 name: "segment with fd duplication still matches prefix rule",
220 subject: `go test ./... 2>&1 | head -20`,
221 want: Allow,
222 },
223 {
224 name: "read-only segment with dev null redirect auto-allows",
225 subject: `git log --oneline 2>/dev/null | head -20`,
226 want: Allow,
227 },
228 {
229 name: "write-capable read-only-looking arg still asks after safe redirect",
230 subject: `git diff --output changes.patch 2>/dev/null | head -20`,
231 want: Ask,
232 },
233 {
234 name: "atomic subject with matching prefix rule still allows",
235 subject: "git push origin main",
236 want: Allow,
237 },
238 }
239 for _, tt := range cases {
240 t.Run(tt.name, func(t *testing.T) {
241 got := p.DecideSubject("bash", false, tt.subject)
242 if got != tt.want {
243 t.Errorf("DecideSubject(%q) = %v, want %v", tt.subject, got, tt.want)
244 }
245 })
246 }
247 }
248
249 func TestPolicyDecideCompoundBashUsesWriterFallback(t *testing.T) {
250 command := `$file = Get-ChildItem -Path "D:\fixtures\reports" -Filter "*sample*.txt" | Select-Object -First 1; python -c "import sys; f=open(sys.argv[1], 'r', encoding='utf-8'); print(f.read()[:10000]); f.close()" $file.FullName`
251
252 cases := []struct {
253 name string
254 mode string
255 want Decision
256 }{
257 {
258 name: "auto writer fallback asks for dynamic compound bash segments",
259 mode: "allow",
260 want: Ask,
261 },
262 {
263 name: "ask writer fallback still prompts for uncovered compound bash segments",
264 mode: "ask",
265 want: Ask,
266 },
267 {
268 name: "deny writer fallback blocks dynamic compound bash segments",
269 mode: "deny",
270 want: Deny,
271 },
272 }
273 for _, tt := range cases {
274 t.Run(tt.name, func(t *testing.T) {
275 p := New(tt.mode, nil, nil, nil)
276 if got := p.DecideSubject("bash", false, command); got != tt.want {
277 t.Fatalf("DecideSubject(mode=%q) = %v, want %v", tt.mode, got, tt.want)
278 }
279 })
280 }
281 }
282
283 func TestPolicyDecideCompoundBashPreservesWholeCommandRules(t *testing.T) {
284 subject := `git add . && git commit -m "wip" && git push`
285
286 t.Run("exact allow still wins before segment decomposition", func(t *testing.T) {
287 p := New("ask", []string{`Bash(git add . && git commit -m "wip" && git push)`}, nil, nil)
288 if got := p.DecideSubject("bash", false, subject); got != Allow {
289 t.Fatalf("DecideSubject(%q) = %v, want %v", subject, got, Allow)
290 }
291 })
292
293 t.Run("exact deny still beats segment allows", func(t *testing.T) {
294 p := New("ask", []string{
295 "Bash(git add:*)",
296 "Bash(git commit:*)",
297 "Bash(git push:*)",
298 }, nil, []string{`Bash(git add . && git commit -m "wip" && git push)`})
299 if got := p.DecideSubject("bash", false, subject); got != Deny {
300 t.Fatalf("DecideSubject(%q) = %v, want %v", subject, got, Deny)
301 }
302 })
303
304 t.Run("exact ask still beats segment allows", func(t *testing.T) {
305 p := New("allow", []string{
306 "Bash(git add:*)",
307 "Bash(git commit:*)",
308 "Bash(git push:*)",
309 }, []string{`Bash(git add . && git commit -m "wip" && git push)`}, nil)
310 if got := p.DecideSubject("bash", false, subject); got != Ask {
311 t.Fatalf("DecideSubject(%q) = %v, want %v", subject, got, Ask)
312 }
313 })
314 }
315
316 func TestPolicyDecideDynamicCompoundPreservesSegmentDenyAndAsk(t *testing.T) {
317 tests := []struct {
318 name string
319 subject string
320 ask []string
321 deny []string
322 want Decision
323 }{
324 {
325 name: "glob segment deny beats auto fallback",
326 subject: "git status && rm *.log",
327 deny: []string{"Bash(rm *)"},
328 want: Deny,
329 },
330 {
331 name: "redirect segment ask beats auto fallback",
332 subject: "git status && printf result > output.txt",
333 ask: []string{"Bash(printf *)"},
334 want: Ask,
335 },
336 {
337 name: "indirect execution segment deny beats required human ask",
338 subject: `git status && eval "touch /tmp/x"`,
339 deny: []string{"Bash(eval *)"},
340 want: Deny,
341 },
342 }
343
344 for _, tt := range tests {
345 t.Run(tt.name, func(t *testing.T) {
346 p := New("allow", []string{"Bash"}, tt.ask, tt.deny)
347 if got := p.DecideSubject("bash", false, tt.subject); got != tt.want {
348 t.Fatalf("DecideSubject(%q) = %v, want %v", tt.subject, got, tt.want)
349 }
350 })
351 }
352 }
353
353 lines GO