返回 DeepSeek-Reasonix
bash_approval_test.go
根目录 / internal / permission / bash_approval_test.go
1 package permission
2
3 import "testing"
4
5 func TestBashSubjectRequiresExplicitApproval(t *testing.T) {
6 tests := []struct {
7 name string
8 subject string
9 wantHuman bool
10 wantExact bool
11 }{
12 {name: "plain static command", subject: "git status --short"},
13 {name: "static compound command", subject: "git status && npm test"},
14 {name: "safe null redirect", subject: "git status 2>/dev/null"},
15 {name: "simple sudo command", subject: "sudo chmod 644 file"},
16 {name: "non-indirect builtin", subject: "builtin printf '%s\\n' ok"},
17 {name: "command substitution", subject: "git status $(touch /tmp/x)", wantHuman: true, wantExact: true},
18 {name: "backtick substitution", subject: "git status `touch /tmp/x`", wantHuman: true, wantExact: true},
19 {name: "process substitution input", subject: "diff <(touch /tmp/x) expected", wantHuman: true, wantExact: true},
20 {name: "process substitution output", subject: "tee >(touch /tmp/x)", wantHuman: true, wantExact: true},
21 {name: "parameter expansion", subject: "git diff $REV", wantExact: true},
22 {name: "arithmetic expansion", subject: "echo $((1 + 1))", wantExact: true},
23 {name: "brace expansion", subject: "printf '%s\\n' {a,b}", wantExact: true},
24 {name: "extended glob", subject: "printf '%s\\n' @(a|b)", wantExact: true},
25 {name: "environment assignment", subject: "REV=HEAD git diff", wantExact: true},
26 {name: "env wrapper assignment", subject: "env REV=HEAD git diff", wantExact: true},
27 {name: "file redirect", subject: "git status > status.txt", wantExact: true},
28 {name: "unquoted glob", subject: "rm *.log", wantExact: true},
29 {name: "heredoc", subject: "cat <<EOF\nhello\nEOF", wantExact: true},
30 {name: "heredoc nested execution", subject: "cat <<EOF\n$(touch /tmp/x)\nEOF", wantHuman: true, wantExact: true},
31 {name: "eval", subject: `eval "touch /tmp/x"`, wantHuman: true, wantExact: true},
32 {name: "source", subject: "source ./script.sh", wantHuman: true, wantExact: true},
33 {name: "dot source", subject: ". ./script.sh", wantHuman: true, wantExact: true},
34 {name: "builtin eval", subject: `builtin eval "touch /tmp/x"`, wantHuman: true, wantExact: true},
35 {name: "builtin source", subject: "builtin source ./script.sh", wantHuman: true, wantExact: true},
36 {name: "bash command string", subject: `bash -lc "touch /tmp/x"`, wantHuman: true, wantExact: true},
37 {name: "wrapped bash command string", subject: `env bash -c "touch /tmp/x"`, wantHuman: true, wantExact: true},
38 {name: "powershell command string", subject: `pwsh -Command "New-Item x"`, wantHuman: true, wantExact: true},
39 {name: "cmd command string", subject: `cmd /c "echo x > file"`, wantHuman: true, wantExact: true},
40 {name: "python inline code", subject: `python3 -c "open('x','w').close()"`, wantHuman: true, wantExact: true},
41 {name: "node inline code", subject: `node -e "require('fs').writeFileSync('x','')"`, wantHuman: true, wantExact: true},
42 {name: "node attached inline code", subject: `node --eval="require('fs').writeFileSync('x','')"`, wantHuman: true, wantExact: true},
43 {name: "ruby attached inline code", subject: `ruby -eFile.write('x','')`, wantHuman: true, wantExact: true},
44 {name: "cmd attached command string", subject: `cmd /cecho x`, wantHuman: true, wantExact: true},
45 {name: "find exec", subject: `find . -exec touch {} ;`, wantHuman: true, wantExact: true},
46 {name: "awk inline system call", subject: `awk 'BEGIN{system("touch /tmp/x")}'`, wantHuman: true, wantExact: true},
47 {name: "awk inline getline pipe", subject: `awk '{"touch /tmp/x" | getline line}' file`, wantHuman: true, wantExact: true},
48 {name: "awk variant inline program", subject: `gawk '{print $1}' file`, wantHuman: true, wantExact: true},
49 {name: "awk field separator is not a script file", subject: `awk -F: 'BEGIN{system("touch /tmp/x")}' /etc/passwd`, wantHuman: true, wantExact: true},
50 {name: "awk separate field separator is not a script file", subject: `awk -F : 'BEGIN{system("touch /tmp/x")}' /etc/passwd`, wantHuman: true, wantExact: true},
51 {name: "gawk inline source overrides script file", subject: `gawk -f safe.awk -e 'BEGIN{system("touch /tmp/x")}'`, wantHuman: true, wantExact: true},
52 {name: "gawk long inline source overrides script file", subject: `gawk --file=safe.awk --source='BEGIN{system("touch /tmp/x")}'`, wantHuman: true, wantExact: true},
53 {name: "awk script file", subject: `awk -f transform.awk input.txt`},
54 {name: "awk attached script file", subject: `mawk -ftransform.awk input.txt`},
55 {name: "awk long script file", subject: `awk --file=transform.awk input.txt`},
56 {name: "gawk exec script file", subject: `gawk -E transform.awk input.txt`},
57 {name: "gawk long exec script file", subject: `gawk --exec=transform.awk input.txt`},
58 }
59 for _, tt := range tests {
60 t.Run(tt.name, func(t *testing.T) {
61 if got := BashSubjectRequiresExplicitApproval(tt.subject); got != tt.wantHuman {
62 t.Errorf("BashSubjectRequiresExplicitApproval(%q) = %v, want %v", tt.subject, got, tt.wantHuman)
63 }
64 if got := bashSubjectRequiresExactRule(tt.subject); got != tt.wantExact {
65 t.Errorf("bashSubjectRequiresExactRule(%q) = %v, want %v", tt.subject, got, tt.wantExact)
66 }
67 })
68 }
69 }
70
71 func TestPowerShellCmdletDenyPrefixIsCaseInsensitive(t *testing.T) {
72 p := New("allow", nil, nil, []string{
73 "Set-Content",
74 "Bash(Add-Content:*)",
75 "Bash(Out-File:*)",
76 })
77 for _, command := range []string{
78 `set-content -LiteralPath app.go -Value bad`,
79 `ADD-CONTENT -LiteralPath app.go -Value bad`,
80 `out-file -FilePath app.go`,
81 } {
82 if got := p.DecideSubject("bash", false, command); got != Deny {
83 t.Fatalf("DecideSubject(%q) = %v, want Deny", command, got)
84 }
85 }
86 if got := p.DecideSubject("bash", false, `Set-Location src`); got != Allow {
87 t.Fatalf("unrelated PowerShell command = %v, want Allow", got)
88 }
89 }
90
91 func TestPolicyDynamicBashFollowsPresetAndExplicitRules(t *testing.T) {
92 const command = "git status $(touch /tmp/reasonix-permission-bypass)"
93
94 tests := []struct {
95 name string
96 p Policy
97 want Decision
98 }{
99 {name: "workspace fallback allows inside sandbox", p: New("allow", nil, nil, nil), want: Allow},
100 {name: "explicit dynamic fallback opt-in", p: New("allow", nil, nil, nil).WithAllowDynamicBashFallback(true), want: Allow},
101 {name: "dynamic opt-in still requires allow fallback", p: New("ask", nil, nil, nil).WithAllowDynamicBashFallback(true), want: Ask},
102 {name: "dynamic opt-in keeps ask precedence", p: New("allow", nil, []string{"Bash(git*)"}, nil).WithAllowDynamicBashFallback(true), want: Ask},
103 {name: "dynamic opt-in keeps deny precedence", p: New("allow", nil, nil, []string{"Bash(git*)"}).WithAllowDynamicBashFallback(true), want: Deny},
104 {name: "bare allow remains bounded by sandbox", p: New("ask", []string{"Bash"}, nil, nil), want: Ask},
105 {name: "ordinary glob does not grant an indirect command", p: New("ask", []string{"Bash(git*)"}, nil, nil), want: Ask},
106 {name: "legacy prefix does not grant an indirect command", p: New("ask", []string{"Bash(git *)"}, nil, nil), want: Ask},
107 {name: "session glob cannot bypass", p: New("ask", nil, nil, nil).WithSessionAllow([]string{"Bash(git*)"}), want: Ask},
108 {name: "explicit ask remains ask", p: New("allow", []string{"Bash"}, []string{"Bash(git*)"}, nil), want: Ask},
109 {name: "raw deny wins", p: New("allow", []string{"Bash"}, nil, []string{"Bash(git*)"}), want: Deny},
110 {name: "scoped raw deny wins", p: New("allow", []string{"Bash"}, nil, []string{"Bash(git status:*)"}), want: Deny},
111 {name: "scoped raw ask remains ask", p: New("allow", []string{"Bash"}, []string{"Bash(git status:*)"}, nil), want: Ask},
112 {name: "literal allow matches exactly", p: New("ask", []string{"Bash=" + command}, nil, nil), want: Allow},
113 {name: "legacy exact allow matches exactly", p: New("ask", []string{"Bash(" + command + ")"}, nil, nil), want: Allow},
114 {name: "literal session grant matches exactly", p: New("ask", nil, []string{"Bash(git*)"}, nil).WithSessionAllow([]string{"Bash=" + command}), want: Allow},
115 }
116 for _, tt := range tests {
117 t.Run(tt.name, func(t *testing.T) {
118 if got := tt.p.DecideSubject("bash", false, command); got != tt.want {
119 t.Fatalf("DecideSubject(%q) = %v, want %v", command, got, tt.want)
120 }
121 })
122 }
123 }
124
125 func TestPolicyRawBashPrefixMatchesDynamicSpacing(t *testing.T) {
126 command := "git status $(touch /tmp/x)"
127 if got := New("allow", nil, nil, []string{"Bash(git status:*)"}).DecideSubject("bash", false, command); got != Deny {
128 t.Fatalf("scoped deny with dynamic spacing = %v, want Deny", got)
129 }
130 }
131
132 func TestPolicyDynamicBashShapesFollowWorkspaceFallback(t *testing.T) {
133 p := New("allow", []string{"Bash"}, nil, nil)
134 for _, command := range []string{
135 "git status `touch /tmp/x`",
136 "diff <(touch /tmp/x) expected",
137 "tee >(touch /tmp/x)",
138 `eval "touch /tmp/x"`,
139 "source ./script.sh",
140 `builtin eval "touch /tmp/x"`,
141 "builtin source ./script.sh",
142 `bash -c "touch /tmp/x"`,
143 `python3 -c "open('x','w').close()"`,
144 } {
145 if got := p.DecideSubject("bash", true, command); got != Allow {
146 t.Errorf("DecideSubject(%q) = %v, want Allow", command, got)
147 }
148 }
149 }
150
151 func TestPolicyExactOnlyBashUsesFallbackWithoutReusableAllow(t *testing.T) {
152 for _, command := range []string{
153 "git diff $REV",
154 "echo $((1 + 1))",
155 "REV=HEAD git diff",
156 "env REV=HEAD git diff",
157 "git status > status.txt",
158 "rm *.log",
159 "cat <<EOF\nhello\nEOF",
160 } {
161 if got := New("ask", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Ask {
162 t.Errorf("ask fallback for %q = %v, want Ask", command, got)
163 }
164 if got := New("allow", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Allow {
165 t.Errorf("auto fallback for %q = %v, want Allow", command, got)
166 }
167 if got := New("deny", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Deny {
168 t.Errorf("deny fallback for %q = %v, want Deny", command, got)
169 }
170 if got := New("ask", []string{"Bash=" + command}, nil, nil).DecideSubject("bash", false, command); got != Allow {
171 t.Errorf("exact literal for %q = %v, want Allow", command, got)
172 }
173 }
174 }
175
176 func TestPolicyStaticBashRulesRemainReusable(t *testing.T) {
177 tests := []struct {
178 rule string
179 command string
180 }{
181 {rule: "Bash(git status:*)", command: "git status --short"},
182 {rule: "Bash(git *)", command: "git status --short"},
183 {rule: "Bash(git*)", command: "git status --short"},
184 {rule: "Bash", command: "git status --short"},
185 }
186 for _, tt := range tests {
187 p := New("ask", []string{tt.rule}, nil, nil)
188 if got := p.DecideSubject("bash", false, tt.command); got != Allow {
189 t.Errorf("rule %q command %q = %v, want Allow", tt.rule, tt.command, got)
190 }
191 }
192 }
193
194 func TestDynamicBashRuleMatchingAndCoverage(t *testing.T) {
195 const command = "git status $(touch /tmp/x)"
196 if RuleMatchesString("Bash(git*)", "bash", command) {
197 t.Fatal("broad session allow matched dynamic command")
198 }
199 if !RuleMatchesString("Bash="+command, "bash", command) {
200 t.Fatal("literal session allow did not match exact dynamic command")
201 }
202 if RuleCoversString("Bash(git*)", "Bash="+command) {
203 t.Fatal("broad glob covered dynamic literal rule")
204 }
205 if RuleCoversString("Bash", "Bash="+command) {
206 t.Fatal("bare Bash rule covered dynamic literal rule")
207 }
208 if !RuleCoversString("Bash="+command, "Bash="+command) {
209 t.Fatal("identical dynamic literal rules were not deduplicated")
210 }
211 if !RuleCoversString("Bash", "Bash") {
212 t.Fatal("identical bare rules were not deduplicated")
213 }
214 }
215
216 func TestDynamicBashRememberedAsLiteral(t *testing.T) {
217 commands := []string{
218 "git status $(touch /tmp/x)",
219 "rm *.log",
220 `eval "touch /tmp/x"`,
221 }
222 for _, command := range commands {
223 want := "Bash=" + command
224 if got := RememberRuleForScope("bash", command); got != want {
225 t.Errorf("RememberRuleForScope(%q) = %q, want %q", command, got, want)
226 }
227 if got := SessionGrantRuleForScope("bash", command); got != want {
228 t.Errorf("SessionGrantRuleForScope(%q) = %q, want %q", command, got, want)
229 }
230 }
231 }
232
232 lines GO