| 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 |