返回 DeepSeek-Reasonix
permission_extra_test.go
根目录 / internal / permission / permission_extra_test.go
1 package permission
2
3 import (
4 "encoding/json"
5 "testing"
6 )
7
8 // --- ParseDecision ---
9
10 func TestParseDecisionAllow(t *testing.T) {
11 if ParseDecision("allow") != Allow {
12 t.Error("ParseDecision(\"allow\") should be Allow")
13 }
14 if ParseDecision("ALLOW") != Allow {
15 t.Error("ParseDecision(\"ALLOW\") should be Allow")
16 }
17 if ParseDecision(" allow ") != Allow {
18 t.Error("ParseDecision with whitespace should be Allow")
19 }
20 }
21
22 func TestParseDecisionDeny(t *testing.T) {
23 if ParseDecision("deny") != Deny {
24 t.Error("ParseDecision(\"deny\") should be Deny")
25 }
26 if ParseDecision("DENY") != Deny {
27 t.Error("ParseDecision(\"DENY\") should be Deny")
28 }
29 }
30
31 func TestParseDecisionAsk(t *testing.T) {
32 if ParseDecision("ask") != Ask {
33 t.Error("ParseDecision(\"ask\") should be Ask")
34 }
35 }
36
37 func TestParseDecisionUnknown(t *testing.T) {
38 if ParseDecision("unknown") != Ask {
39 t.Error("ParseDecision(\"unknown\") should default to Ask")
40 }
41 if ParseDecision("") != Ask {
42 t.Error("ParseDecision(\"\") should default to Ask")
43 }
44 if ParseDecision(" ") != Ask {
45 t.Error("ParseDecision(\" \") should default to Ask")
46 }
47 }
48
49 // --- Decision.String ---
50
51 func TestDecisionString(t *testing.T) {
52 if Allow.String() != "allow" {
53 t.Errorf("Allow.String() = %q", Allow.String())
54 }
55 if Ask.String() != "ask" {
56 t.Errorf("Ask.String() = %q", Ask.String())
57 }
58 if Deny.String() != "deny" {
59 t.Errorf("Deny.String() = %q", Deny.String())
60 }
61 if Decision(99).String() != "unknown" {
62 t.Errorf("unknown Decision.String() = %q", Decision(99).String())
63 }
64 }
65
66 // --- matchGlob edge cases ---
67
68 func TestMatchGlobEmptyPattern(t *testing.T) {
69 // Empty pattern matches empty name (both consumed simultaneously).
70 if !matchGlob("", "") {
71 t.Error("empty pattern should match empty name")
72 }
73 if matchGlob("", "anything") {
74 t.Error("empty pattern should not match non-empty name")
75 }
76 }
77
78 func TestMatchGlobOnlyStars(t *testing.T) {
79 if !matchGlob("***", "anything") {
80 t.Error("pattern *** should match anything")
81 }
82 if !matchGlob("*", "") {
83 t.Error("pattern * should match empty string")
84 }
85 }
86
87 func TestMatchGlobPatternLongerThanName(t *testing.T) {
88 if matchGlob("abcdefgh", "abc") {
89 t.Error("pattern longer than name should not match")
90 }
91 }
92
93 func TestMatchGlobConsecutiveStars(t *testing.T) {
94 if !matchGlob("a**c", "abc") {
95 t.Error("a**c should match abc")
96 }
97 }
98
99 func TestMatchGlobQuestionMark(t *testing.T) {
100 if !matchGlob("?", "a") {
101 t.Error("? should match single char")
102 }
103 if matchGlob("?", "") {
104 t.Error("? should not match empty")
105 }
106 if matchGlob("?", "ab") {
107 t.Error("? should not match two chars")
108 }
109 }
110
111 // --- Subject edge cases ---
112
113 func TestSubjectNestedJSON(t *testing.T) {
114 // Array values should not match.
115 got := Subject(json.RawMessage(`{"command": ["array", "value"]}`))
116 if got != "" {
117 t.Errorf("array command should return empty, got %q", got)
118 }
119 }
120
121 func TestSubjectNullValue(t *testing.T) {
122 got := Subject(json.RawMessage(`{"command": null}`))
123 if got != "" {
124 t.Errorf("null command should return empty, got %q", got)
125 }
126 }
127
128 func TestSubjectEmptyCommand(t *testing.T) {
129 got := Subject(json.RawMessage(`{"command": ""}`))
130 if got != "" {
131 t.Errorf("empty command should return empty, got %q", got)
132 }
133 }
134
135 func TestSubjectPriority(t *testing.T) {
136 // command > file_path > path > pattern
137 got := Subject(json.RawMessage(`{"pattern":"pat","path":"/p","file_path":"/f","command":"cmd"}`))
138 if got != "cmd" {
139 t.Errorf("priority: got %q, want cmd", got)
140 }
141 }
142
143 func TestSubjectMoveFilePaths(t *testing.T) {
144 got := Subject(json.RawMessage(`{"source_path":"a.md","destination_path":"docs/a.md"}`))
145 if got != "a.md" {
146 t.Errorf("move_file subject = %q, want source path", got)
147 }
148 }
149
150 // --- rememberRule ---
151
152 func TestRememberRuleWithBashSubjectUsesPrefixWhenAvailable(t *testing.T) {
153 // Bash commands with a safe prefix prefer the prefix over the exact command
154 // so "always allow" covers similar invocations (e.g. different search terms).
155 got := rememberRule("bash", "go test ./...")
156 if got != "Bash(go test:*)" {
157 t.Errorf("rememberRule = %q, want Bash(go test:*)", got)
158 }
159 if r, ok := ParseRule(got); !ok || r.Literal || r.Tool != "Bash" || r.Subject != "go test:*" {
160 t.Errorf("ParseRule(%q) = {%q,%q,lit=%v,ok=%v}", got, r.Tool, r.Subject, r.Literal, ok)
161 }
162 // Verify the prefix rule matches similar commands.
163 if !RuleMatchesString(got, "bash", "go test ./...") {
164 t.Errorf("prefix rule should match the exact command")
165 }
166 if !RuleMatchesString(got, "bash", "go test ./internal/control") {
167 t.Errorf("prefix rule should match similar go test command")
168 }
169 if RuleMatchesString(got, "bash", "go build ./...") {
170 t.Errorf("prefix rule should not match different go subcommand")
171 }
172 }
173
174 func TestRememberRuleForBashUsesPrefixWhenAvailable(t *testing.T) {
175 got := RememberRuleForScope("bash", "go test ./...")
176 if got != "Bash(go test:*)" {
177 t.Errorf("RememberRuleForScope prefix = %q", got)
178 }
179 if !RuleMatchesString(got, "bash", "go test ./internal/control") {
180 t.Errorf("prefix rule should match similar go test command")
181 }
182 if !RuleMatchesString(got, "bash", "go test") {
183 t.Errorf("prefix rule should match the base command without extra args")
184 }
185 if RuleMatchesString(got, "bash", "go build ./...") {
186 t.Errorf("prefix rule should not match different go subcommand")
187 }
188 if RuleMatchesString(got, "bash", "go testing ./...") {
189 t.Errorf("prefix rule should not match partial command words")
190 }
191 if RuleMatchesString(got, "bash", "go test ./... && rm -rf /tmp/x") {
192 t.Errorf("prefix rule should not match commands with shell syntax")
193 }
194 if !RuleMatchesString("Bash(go test *)", "bash", "go test ./legacy") {
195 t.Errorf("legacy space-star prefix should still match similar commands")
196 }
197 if RuleMatchesString("Bash(go test *)", "bash", "go test ./legacy && rm -rf /tmp/x") {
198 t.Errorf("legacy space-star prefix should not match commands with shell syntax")
199 }
200 if !RuleMatchesString("Bash(go test:*)", "bash", `go "test" ./legacy`) {
201 t.Errorf("prefix rule should match statically quoted command fields")
202 }
203 if RuleMatchesString("Bash(go test:*)", "bash", `go "$subcmd" ./legacy`) {
204 t.Errorf("prefix rule should not match dynamic command fields")
205 }
206 }
207
208 func TestBashPrefixRulesMatchSafeRedirectsOnly(t *testing.T) {
209 safe := []string{
210 "git log 2>/dev/null",
211 "git log 2> /dev/null",
212 "git log >/dev/null",
213 "git log >>/dev/null",
214 "git log &>/dev/null",
215 "git log >$null",
216 "git log >NUL",
217 "git log 2>&1",
218 "git log >&2",
219 }
220 for _, cmd := range safe {
221 if !RuleMatchesString("Bash(git log:*)", "bash", cmd) {
222 t.Errorf("prefix rule should match safe redirect command %q", cmd)
223 }
224 }
225
226 unsafe := []string{
227 "git log > out.txt",
228 "git log 2>out.txt",
229 "git log < input.txt",
230 "git log >$nullish",
231 "git log >nul.txt",
232 "git log 2>&1rm",
233 "git log >/dev/null && rm -rf /tmp/x",
234 "git log 2>&1 && rm -rf /tmp/x",
235 "git log >/dev/null\nrm -rf /tmp/x",
236 }
237 for _, cmd := range unsafe {
238 if RuleMatchesString("Bash(git log:*)", "bash", cmd) {
239 t.Errorf("prefix rule should not match unsafe shell command %q", cmd)
240 }
241 }
242 }
243
244 func TestRememberRuleWithFileSubjectIsToolWide(t *testing.T) {
245 // File mutation tools are remembered tool-wide so "always allow editing"
246 // covers any file, matching the session-grant behaviour.
247 got := rememberRule("edit_file", "src/app.go")
248 if got != "Edit" {
249 t.Errorf("rememberRule = %q, want Edit", got)
250 }
251 if r, ok := ParseRule(got); !ok || r.Literal || r.Tool != "Edit" || r.Subject != "" {
252 t.Errorf("ParseRule(%q) = {%q,%q,lit=%v,ok=%v}", got, r.Tool, r.Subject, r.Literal, ok)
253 }
254 }
255
256 // TestPersistedEditRuleIsToolWide asserts a deliberate design choice: when a
257 // user persists an "always allow" for a file-mutation tool, the saved rule is
258 // "Edit" — tool-wide, with no path restriction. This means approving one
259 // edit_file call and choosing "Always allow (save to config)" grants blanket
260 // edit permission for every file, across sessions, for every file-mutation
261 // tool (write_file, multi_edit, etc.). Deny rules still take precedence.
262 func TestPersistedEditRuleIsToolWide(t *testing.T) {
263 rule := RememberRuleForScope("edit_file", "src/app.go")
264 if rule != "Edit" {
265 t.Fatalf("persisted rule = %q, want tool-wide Edit (no path restriction)", rule)
266 }
267 // The tool-wide Edit rule matches any file-mutation tool on any file.
268 allMutationTools := []string{"write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol"}
269 for _, tm := range allMutationTools {
270 if !RuleMatchesString(rule, tm, "any/path/at/all.txt") {
271 t.Errorf("tool-wide Edit should match %s on any path", tm)
272 }
273 }
274 // It must NOT match non-mutation tools (otherwise a denylist would be
275 // needed for every tool, which isn't the intent).
276 if RuleMatchesString(rule, "bash", "rm -rf /") {
277 t.Errorf("tool-wide Edit must not match bash")
278 }
279 }
280
281 func TestRememberRuleWithoutSubject(t *testing.T) {
282 got := rememberRule("ls", "")
283 if got != "ls" {
284 t.Errorf("rememberRule = %q", got)
285 }
286 }
287
288 func TestSessionGrantKeyScopesBashByCommand(t *testing.T) {
289 a := SessionGrantKey("bash", "go build")
290 b := SessionGrantKey("bash", "go test ./...")
291 if a == b {
292 t.Fatalf("bash session grant keys should differ by command: %q", a)
293 }
294 }
295
296 func TestSessionGrantKeyGroupsFileMutationTools(t *testing.T) {
297 a := SessionGrantKey("edit_file", "src/a.go")
298 b := SessionGrantKey("write_file", "src/b.go")
299 if a != b {
300 t.Fatalf("file mutation session grant keys should match, got %q and %q", a, b)
301 }
302 }
303
304 func TestSessionGrantRuleForBashUsesPrefix(t *testing.T) {
305 got := SessionGrantRuleForScope("bash", "npm run test -- --watch")
306 if got != "Bash(npm run test:*)" {
307 t.Errorf("SessionGrantRuleForScope prefix = %q", got)
308 }
309 if !RuleMatchesString(got, "bash", "npm run test -- src") {
310 t.Errorf("prefix session rule should match same package script")
311 }
312 if RuleMatchesString(got, "bash", "npm run build") {
313 t.Errorf("prefix session rule should not match another package script")
314 }
315 }
316
317 func TestBashCommandPrefixRejectsShellSyntax(t *testing.T) {
318 if got := BashCommandPrefix("go test ./... && rm -rf /tmp/x"); got != "" {
319 t.Errorf("BashCommandPrefix with shell syntax = %q, want empty", got)
320 }
321 if got := BashCommandPrefix("rm -rf /tmp/x"); got != "" {
322 t.Errorf("BashCommandPrefix dangerous command = %q, want empty", got)
323 }
324 if got := BashCommandPrefix("go test ./..."); got != "go test:*" {
325 t.Errorf("BashCommandPrefix = %q", got)
326 }
327 }
328
329 func TestRuleCoversString(t *testing.T) {
330 cases := []struct {
331 existing string
332 candidate string
333 want bool
334 }{
335 {"Bash(go test:*)", "Bash(go test ./...)", true},
336 {"Bash(go test *)", "Bash(go test ./...)", true}, // legacy generated prefix
337 {"bash(go test *)", "Bash(go test)", true},
338 {"bash=go test ./...", "Bash(go test ./...)", true},
339 {"Bash(go test *)", "Bash(go test:*)", true}, // existing legacy prefix covers the new shape
340 {"Bash(go test:*)", "Bash(go test *)", true}, // new prefix prunes legacy prefix on save
341 {"Bash(go test ./...)", "Bash(go test:*)", false},
342 {"Edit", "Edit(src/app.go)", true},
343 {"file_mutation", "Edit(src/app.go)", true},
344 {"Edit(src/app.go)", "Edit", false},
345 {"Bash(go test:*)", "Bash(go build ./...)", false},
346 }
347 for _, c := range cases {
348 if got := RuleCoversString(c.existing, c.candidate); got != c.want {
349 t.Errorf("RuleCoversString(%q, %q) = %v, want %v", c.existing, c.candidate, got, c.want)
350 }
351 }
352 }
353
354 func TestFileMutationRuleMatchesMutationToolsByPath(t *testing.T) {
355 p := New("ask", []string{"Edit(src/app.go)"}, nil, nil)
356
357 if got := p.Decide("write_file", false, json.RawMessage(`{"path":"src/app.go"}`)); got != Allow {
358 t.Errorf("write_file same path = %v, want Allow", got)
359 }
360 if got := p.Decide("multi_edit", false, json.RawMessage(`{"path":"src/app.go"}`)); got != Allow {
361 t.Errorf("multi_edit same path = %v, want Allow", got)
362 }
363 if got := p.Decide("edit_file", false, json.RawMessage(`{"path":"src/other.go"}`)); got == Allow {
364 t.Errorf("edit_file different path = %v, want not Allow", got)
365 }
366 if got := p.Decide("bash", false, json.RawMessage(`{"command":"cat src/app.go"}`)); got == Allow {
367 t.Errorf("bash should not match Edit rule")
368 }
369 }
370
371 // --- New ---
372
373 func TestNewPolicy(t *testing.T) {
374 p := New("deny",
375 []string{"ls"},
376 []string{"read_file"},
377 []string{"bash(rm*)"},
378 )
379 if p.Mode != Deny {
380 t.Errorf("Mode = %v", p.Mode)
381 }
382 if len(p.Allow) != 1 {
383 t.Errorf("Allow count = %d", len(p.Allow))
384 }
385 if len(p.Ask) != 1 {
386 t.Errorf("Ask count = %d", len(p.Ask))
387 }
388 if len(p.Deny) != 1 {
389 t.Errorf("Deny count = %d", len(p.Deny))
390 }
391 }
392
393 // --- NewGate ---
394
395 func TestNewGate(t *testing.T) {
396 p := New("ask", nil, nil, nil)
397 g := NewGate(p, nil)
398 if g.Policy.Mode != Ask {
399 t.Errorf("Policy.Mode = %v", g.Policy.Mode)
400 }
401 if g.Approver != nil {
402 t.Error("Approver should be nil")
403 }
404 }
405
405 lines GO