返回 DeepSeek-Reasonix
rule_diagnostics.go
根目录 / internal / permission / rule_diagnostics.go
1 package permission
2
3 import (
4 "strings"
5 "unicode"
6 )
7
8 // RuleDefect says why a configured rule can never match.
9 type RuleDefect int
10
11 const (
12 // DefectNoSuchTool marks a rule whose tool name contains whitespace, which
13 // no tool answers to: the author wrote a shell command where a tool name
14 // belongs.
15 DefectNoSuchTool RuleDefect = iota
16 // DefectUnclosedSubject marks a rule that opens a subject parenthesis and
17 // never closes it. ParseRule then reads the whole entry as one tool name,
18 // so the rule is unmatchable for a reason the author can fix by typing one
19 // character rather than by rewriting the entry.
20 DefectUnclosedSubject
21 )
22
23 // UnmatchableRule is a configured rule that no tool call can satisfy, paired
24 // with the rewrite that expresses what the author meant.
25 type UnmatchableRule struct {
26 // List is "allow", "ask" or "deny" — which setting carried the rule.
27 List string
28 // Rule is the entry exactly as configured.
29 Rule string
30 // Defect says what is wrong with Rule, so a caller can name the cause
31 // instead of only offering a replacement.
32 Defect RuleDefect
33 // Suggestion is a rule that parses and matches what the entry named, never
34 // a wider grant than it asked for: on the allow list a bare command becomes
35 // an exact rule, not a prefix that would also admit unlisted arguments.
36 Suggestion string
37 }
38
39 // UnmatchableRules reports configured rules that cannot match any tool call.
40 // ParseRule treats only an empty tool name as malformed, so an entry like
41 // "git push --force" parses and installs a rule keyed on a tool nothing
42 // answers to: a deny the author believes is enforced never fires. Whitespace
43 // in a tool name is an exact test, not a guess — a tool name is an identifier
44 // and never contains any, which TestNoBuiltinToolNameHasWhitespace pins — so
45 // this cannot accuse a plugin or MCP tool that merely is not registered yet.
46 // Both sides ask unicode.IsSpace so the check and its invariant cannot drift
47 // apart.
48 func UnmatchableRules(allow, ask, deny []string) []UnmatchableRule {
49 var out []UnmatchableRule
50 for _, group := range []struct {
51 list string
52 rules []string
53 }{{"allow", allow}, {"ask", ask}, {"deny", deny}} {
54 for _, raw := range group.rules {
55 if finding, ok := unmatchableRule(group.list, raw); ok {
56 out = append(out, finding)
57 }
58 }
59 }
60 return out
61 }
62
63 func unmatchableRule(list, raw string) (UnmatchableRule, bool) {
64 rule, ok := ParseRule(raw)
65 if !ok {
66 return UnmatchableRule{}, false
67 }
68 trimmed := strings.TrimSpace(raw)
69 // A "(" in a parsed tool name can only come from ParseRule's fallback
70 // branch, reached when the entry opens a subject it never closes; the
71 // literal "tool=subject" form parses earlier, so it is not caught here.
72 if !rule.Literal && strings.Contains(rule.Tool, "(") {
73 return UnmatchableRule{
74 List: list,
75 Rule: trimmed,
76 Defect: DefectUnclosedSubject,
77 Suggestion: closedSubjectSuggestion(list, trimmed),
78 }, true
79 }
80 if strings.IndexFunc(rule.Tool, unicode.IsSpace) < 0 {
81 return UnmatchableRule{}, false
82 }
83 return UnmatchableRule{
84 List: list,
85 Rule: trimmed,
86 Defect: DefectNoSuchTool,
87 Suggestion: bashRuleSuggestion(list, rule),
88 }, true
89 }
90
91 // closedSubjectSuggestion repairs the missing ")" and then re-judges the
92 // result, so an entry that is wrong in both ways ("git push(--force") is not
93 // handed back a rule that still matches nothing.
94 func closedSubjectSuggestion(list, trimmed string) string {
95 repaired := trimmed + ")"
96 rule, ok := ParseRule(repaired)
97 if !ok {
98 return repaired
99 }
100 if strings.IndexFunc(rule.Tool, unicode.IsSpace) >= 0 {
101 return bashRuleSuggestion(list, rule)
102 }
103 return repaired
104 }
105
106 // bashRuleSuggestion renders the rule the author probably wanted. One that
107 // already carries a subject keeps it verbatim rather than guessing a second
108 // time. For a bare command the form depends on the list: deny and ask get the
109 // prefix rule, so every invocation of the command is covered, while allow gets
110 // the exact command, because a prefix there would silently grant arguments the
111 // author never wrote — "git branch" would also run "git branch -D".
112 func bashRuleSuggestion(list string, rule Rule) string {
113 command := strings.TrimSpace(rule.Tool)
114 if subject := strings.TrimSpace(rule.Subject); subject != "" {
115 return "Bash(" + command + " " + subject + ")"
116 }
117 if list == "allow" {
118 return "Bash(" + command + ")"
119 }
120 return "Bash(" + command + ":*)"
121 }
122
122 lines GO