返回 DeepSeek-Reasonix
rule_diagnostics_test.go
根目录 / internal / permission / rule_diagnostics_test.go
1 package permission
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 // TestUnmatchableRulesReportsBareShellCommands covers the misconfiguration in
9 // #6692: the rules were typed as bare commands, so every one of them named a
10 // tool that does not exist and the deny list never fired.
11 func TestUnmatchableRulesReportsBareShellCommands(t *testing.T) {
12 got := UnmatchableRules(
13 []string{"git status", "Bash(git diff:*)"},
14 []string{"git push --force"},
15 []string{"git checkout HEAD --"},
16 )
17 if len(got) != 3 {
18 t.Fatalf("got %d findings, want 3: %+v", len(got), got)
19 }
20 want := []UnmatchableRule{
21 {List: "allow", Rule: "git status", Defect: DefectNoSuchTool, Suggestion: "Bash(git status)"},
22 {List: "ask", Rule: "git push --force", Defect: DefectNoSuchTool, Suggestion: "Bash(git push --force:*)"},
23 {List: "deny", Rule: "git checkout HEAD --", Defect: DefectNoSuchTool, Suggestion: "Bash(git checkout HEAD --:*)"},
24 }
25 for i, w := range want {
26 if got[i] != w {
27 t.Errorf("finding %d = %+v, want %+v", i, got[i], w)
28 }
29 }
30 }
31
32 // TestUnmatchableRulesLeavesWorkingRulesAlone is the guardrail that keeps the
33 // warning trustworthy: a rule that does match must never be reported, or the
34 // notice trains its reader to ignore it.
35 func TestUnmatchableRulesLeavesWorkingRulesAlone(t *testing.T) {
36 ok := []string{
37 "Bash(git status:*)",
38 "Bash",
39 "edit_file",
40 "edit_file(src/**)",
41 "mcp__github__create_issue",
42 "Bash(git commit -m *)",
43 "read_file=exact/path.go",
44 "",
45 }
46 if got := UnmatchableRules(ok, nil, nil); len(got) != 0 {
47 t.Fatalf("working rules were reported as unmatchable: %+v", got)
48 }
49 }
50
51 // TestUnmatchableRulesKeepsAnExistingSubject checks the suggestion does not
52 // guess twice: a rule that already carries a subject keeps it.
53 func TestUnmatchableRulesKeepsAnExistingSubject(t *testing.T) {
54 got := UnmatchableRules(nil, nil, []string{"git push(--force)"})
55 if len(got) != 1 {
56 t.Fatalf("got %d findings, want 1: %+v", len(got), got)
57 }
58 if want := "Bash(git push --force)"; got[0].Suggestion != want {
59 t.Fatalf("suggestion = %q, want %q", got[0].Suggestion, want)
60 }
61 }
62
63 // TestUnmatchableRuleSuggestionsParseBack closes the loop: every suggestion
64 // the warning prints must itself be a rule that parses and targets bash.
65 func TestUnmatchableRuleSuggestionsParseBack(t *testing.T) {
66 for _, r := range UnmatchableRules([]string{"git status"}, []string{"rm -rf /"}, []string{"git stash drop"}) {
67 rule, ok := ParseRule(r.Suggestion)
68 if !ok {
69 t.Errorf("suggestion %q does not parse", r.Suggestion)
70 continue
71 }
72 if canonicalRuleTool(rule.Tool) != "bash" {
73 t.Errorf("suggestion %q targets %q, want the bash tool", r.Suggestion, rule.Tool)
74 }
75 if !strings.Contains(r.Suggestion, "Bash(") {
76 t.Errorf("suggestion %q is not in Bash(...) form", r.Suggestion)
77 }
78 }
79 }
80
81 // TestUnmatchableRulesCatchesEveryWhitespace keeps the check and the invariant
82 // it rests on asking the same question: a tool name broken across lines is as
83 // unmatchable as one with a plain space.
84 func TestUnmatchableRulesCatchesEveryWhitespace(t *testing.T) {
85 for _, raw := range []string{"git\tstatus", "git\nstatus", "git status"} {
86 if got := UnmatchableRules(nil, nil, []string{raw}); len(got) != 1 {
87 t.Errorf("UnmatchableRules(%q) returned %d findings, want 1", raw, len(got))
88 }
89 }
90 }
91
92 // TestUnmatchableRulesReportsUnclosedSubject covers the entry that is nearly
93 // right: a rule missing its closing parenthesis parses as one long tool name,
94 // so it must be named as the typo it is instead of being wrapped in a second
95 // Bash(...) that would parse but still match nothing.
96 func TestUnmatchableRulesReportsUnclosedSubject(t *testing.T) {
97 tests := []struct {
98 name string
99 list string
100 rule string
101 want UnmatchableRule
102 }{
103 {
104 name: "deny keeps the subject the author typed",
105 list: "deny",
106 rule: "Bash(git status:*",
107 want: UnmatchableRule{List: "deny", Rule: "Bash(git status:*", Defect: DefectUnclosedSubject, Suggestion: "Bash(git status:*)"},
108 },
109 {
110 // Wrong in both ways at once: closing the parenthesis alone still
111 // leaves a tool named "git push".
112 name: "a command in the tool position is repaired too",
113 list: "deny",
114 rule: "git push(--force",
115 want: UnmatchableRule{List: "deny", Rule: "git push(--force", Defect: DefectUnclosedSubject, Suggestion: "Bash(git push --force)"},
116 },
117 }
118 for _, tt := range tests {
119 t.Run(tt.name, func(t *testing.T) {
120 got := UnmatchableRules(nil, nil, []string{tt.rule})
121 if len(got) != 1 {
122 t.Fatalf("got %d findings, want 1: %+v", len(got), got)
123 }
124 if got[0] != tt.want {
125 t.Fatalf("finding = %+v, want %+v", got[0], tt.want)
126 }
127 })
128 }
129 }
130
131 // TestUnmatchableRuleSuggestionsAreThemselvesMatchable closes the trap this
132 // change is about: a suggestion must not be another rule that parses and then
133 // matches nothing, or the warning would send its reader in a circle.
134 func TestUnmatchableRuleSuggestionsAreThemselvesMatchable(t *testing.T) {
135 bad := []string{"git status", "Bash(git status:*", "git push(--force", "rm -rf /"}
136 for _, r := range UnmatchableRules(bad, bad, bad) {
137 if again := UnmatchableRules([]string{r.Suggestion}, nil, nil); len(again) != 0 {
138 t.Errorf("suggestion %q for %q is itself unmatchable: %+v", r.Suggestion, r.Rule, again)
139 }
140 }
141 }
142
143 // TestUnmatchableAllowSuggestionDoesNotWidenTheGrant pins the asymmetry: the
144 // prefix form is right for deny and ask, where covering every argument is the
145 // safe direction, but on the allow list it would auto-run arguments the author
146 // never granted.
147 func TestUnmatchableAllowSuggestionDoesNotWidenTheGrant(t *testing.T) {
148 got := UnmatchableRules([]string{"git branch"}, nil, nil)
149 if len(got) != 1 {
150 t.Fatalf("got %d findings, want 1: %+v", len(got), got)
151 }
152 if want := "Bash(git branch)"; got[0].Suggestion != want {
153 t.Fatalf("suggestion = %q, want %q", got[0].Suggestion, want)
154 }
155
156 // The claim is about what the suggested rule does, so check the policy it
157 // builds rather than only its text.
158 p := New("ask", []string{got[0].Suggestion}, nil, nil)
159 if d := p.DecideSubject("bash", false, "git branch"); d != Allow {
160 t.Errorf("the command the author named decides %v, want allow", d)
161 }
162 if d := p.DecideSubject("bash", false, "git branch -D main"); d == Allow {
163 t.Error("the suggested allow rule also grants \"git branch -D main\"")
164 }
165
166 // The same command on deny keeps the prefix form, which must cover the
167 // arguments the allow form deliberately leaves out.
168 denySuggestion := UnmatchableRules(nil, nil, []string{"git branch"})[0].Suggestion
169 if want := "Bash(git branch:*)"; denySuggestion != want {
170 t.Fatalf("deny suggestion = %q, want %q", denySuggestion, want)
171 }
172 if d := New("ask", nil, nil, []string{denySuggestion}).DecideSubject("bash", false, "git branch -D main"); d != Deny {
173 t.Errorf("the suggested deny rule decides %v for \"git branch -D main\", want deny", d)
174 }
175 }
176
176 lines GO