Vectrum
Sign In

approval: permission rules in Claude Code's syntax, deny before anything else

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 219352ecommit6158e7b

Showing 3 changed files with 1306 additions and 1 deletion.

+16−1internal/approval/policy.goView File
@@ -90,6 +90,14 @@ type Policy struct {
9090 // fired around the prompt rather than around Approve, so a call allowed
9191 // outright or remembered from earlier says nothing.
9292 Hooks *hooks.Hooks
93+
94+ // Rules are standing decisions written down in Claude Code's syntax —
95+ // --allowedTools and --disallowedTools, or a settings file. A deny rule
96+ // is checked before anything else, YOLO and the read-only tools included:
97+ // it is a statement about this repository, and skipping the prompts is
98+ // not a reason to skip that. An allow rule stands in for a "yes" that
99+ // would otherwise have been asked for.
100+ Rules Ruleset
93101 }
94102
95103 // New builds a policy.
@@ -108,7 +116,14 @@ func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage
108116 // remembered for the whole session, not per agent: "always allow bash" is a
109117 // statement about this working tree, and an agent id is not a trust boundary.
110118 func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.RawMessage) error {
111- if p == nil || p.YOLO || readOnly[tool] {
119+ if p == nil {
120+ return nil
121+ }
122+ verdict, rule := p.Rules.Check(tool, input)
123+ if verdict == Denied {
124+ return fmt.Errorf("%s was not run: the rule %s denies it, and no approval can override that. Do not retry it; find another way or explain what you need", tool, rule)
125+ }
126+ if p.YOLO || readOnly[tool] || verdict == Allowed {
112127 return nil
113128 }
114129 req := Describe(tool, input)
+865−0internal/approval/rules.goaddedView File
@@ -0,0 +1,865 @@
1+ package approval
2+
3+ // Permission rules in Claude Code's syntax.
4+ //
5+ // A script written for `claude -p` says what the agent may do with
6+ // --allowedTools and --disallowedTools, and a repository says the same thing in
7+ // .claude/settings.json under permissions.allow and permissions.deny. Both are
8+ // lists of rules in one grammar, and this file is the one reading of it, so
9+ // that a rule means the same thing wherever Codey found it.
10+ //
11+ // The API, for anything that has a list of rule strings in hand:
12+ //
13+ // rule, err := approval.ParseRule("Bash(npm run test:*)")
14+ // rule.Matches("bash", input) // the rule covers the whole call
15+ // rule.Touches("bash", input) // the rule covers some part of the call
16+ //
17+ // set := approval.Ruleset{Allow: allowRules, Deny: denyRules}
18+ // verdict, rule := set.Check("bash", input) // Allowed, Denied or Unmatched
19+ // policy.Rules = set // and the policy consults it
20+ //
21+ // SplitRules cuts a flag value or a settings entry into rule strings, and
22+ // ParseRules parses a list of them, keeping the good ones and reporting the
23+ // rest. An error from ParseRule is a reason to warn, not to stop: a rule
24+ // naming a tool Codey does not have is a rule about nothing here.
25+ //
26+ // Two readings of "matches" exist because allowing and denying need opposite
27+ // kinds of caution. A compound shell command is allowed only when every
28+ // simple command in it is covered by some allow rule, so `git status; rm -rf
29+ // ~` does not ride in on Bash(git status:*). It is denied when any one of them
30+ // hits a deny rule. A command the shell analysis cannot see through — command
31+ // substitution, an inline interpreter script, a parse error — is never allowed
32+ // by a rule, and is denied if anything that can be read out of it matches.
33+ //
34+ // Deny rules are a guard rail, not a sandbox, for the reason shell.go gives:
35+ // a determined command can always be spelled so that no string match sees it.
36+ // They catch the command the model would ordinarily write.
37+
38+ import (
39+ "encoding/json"
40+ "errors"
41+ "fmt"
42+ "net/url"
43+ "os"
44+ "path"
45+ "path/filepath"
46+ "strings"
47+ "unicode"
48+
49+ "mvdan.cc/sh/v3/syntax"
50+ )
51+
52+ // ErrUnknownTool is wrapped by ParseRule when a rule names a tool Codey has no
53+ // counterpart for. Callers warn and carry on.
54+ var ErrUnknownTool = errors.New("unknown tool")
55+
56+ // specKind is what the text inside a rule's parentheses is about.
57+ type specKind int
58+
59+ const (
60+ specNone specKind = iota // the rule takes no argument
61+ specBash // a command, or a command prefix
62+ specPath // a gitignore-style path pattern
63+ specDomain // domain:host, for web_fetch
64+ )
65+
66+ // ccTool is how one Claude Code tool name lands in Codey.
67+ type ccTool struct {
68+ // whole is what the bare name covers; scoped is what a rule with an
69+ // argument covers. They differ for Read, whose path rules Claude Code
70+ // applies to its search tools as well, and they are the same elsewhere.
71+ whole, scoped []string
72+ spec specKind
73+ }
74+
75+ // ccTools maps Claude Code's tool names onto Codey's.
76+ //
77+ // Edit covers write_file as well as edit_file because Claude Code says its
78+ // Edit rules apply to every built-in tool that edits files, and Write is one;
79+ // a script that disallows Edit wants no edits, whichever tool would make them.
80+ // MultiEdit is Claude Code's several-replacements edit, which edit_file is.
81+ var ccTools = map[string]ccTool{
82+ "Bash": {whole: []string{"bash"}, spec: specBash},
83+ "Read": {whole: []string{"read_file"}, scoped: []string{"read_file", "glob", "grep"}, spec: specPath},
84+ "Edit": {whole: []string{"edit_file", "write_file"}, spec: specPath},
85+ "MultiEdit": {whole: []string{"edit_file"}, spec: specPath},
86+ "Write": {whole: []string{"write_file"}, spec: specPath},
87+ "Glob": {whole: []string{"glob"}},
88+ "Grep": {whole: []string{"grep"}},
89+ "WebFetch": {whole: []string{"web_fetch"}, spec: specDomain},
90+ "WebSearch": {whole: []string{"web_search"}},
91+ "TodoWrite": {whole: []string{"todo_write"}},
92+ "Task": {whole: []string{"spawn_agent"}},
93+ "Agent": {whole: []string{"spawn_agent"}},
94+ "BashOutput": {whole: []string{"process_output"}},
95+ "KillShell": {whole: []string{"process_kill"}},
96+ "KillBash": {whole: []string{"process_kill"}},
97+ "Skill": {whole: []string{"skill"}},
98+ "AskUserQuestion": {whole: []string{"ask_user"}},
99+ }
100+
101+ // codeyTools are Codey's own names, accepted as they are, so a rule can name a
102+ // tool Claude Code has no word for. The ones that take an argument take the
103+ // same argument as their Claude Code counterpart.
104+ var codeyTools = map[string]specKind{
105+ "bash": specBash, "read_file": specPath, "edit_file": specPath, "write_file": specPath,
106+ "glob": specNone, "grep": specNone, "web_fetch": specDomain, "web_search": specNone,
107+ "todo_write": specNone, "process_output": specNone, "process_kill": specNone,
108+ "skill": specNone, "ask_user": specNone, "context_status": specNone,
109+ "spawn_agent": specNone, "agent_wait": specNone, "lsp": specNone,
110+ }
111+
112+ // mcpPrefix is how an MCP tool's registered name begins, here as in Claude Code.
113+ const mcpPrefix = "mcp__"
114+
115+ // Rule is one parsed permission rule.
116+ type Rule struct {
117+ // Raw is the rule as it was written, for messages.
118+ Raw string
119+ // Tools are the Codey tool names the rule is about. For an MCP rule it
120+ // holds the one name, which is a whole server when it has no tool part.
121+ Tools []string
122+ // Specifier is the text inside the parentheses, empty for a rule about the
123+ // whole tool.
124+ Specifier string
125+ // Root is the directory relative path patterns are read against — the
126+ // working root for a flag, the project for a settings file. Empty means
127+ // paths are compared as the tool call gave them.
128+ Root string
129+ // Home is what a leading ~/ in a path pattern stands for. Empty means the
130+ // current user's home directory.
131+ Home string
132+
133+ spec specKind
134+ mcp bool
135+ }
136+
137+ // SplitRules cuts a list of rules into its items. Claude Code accepts them
138+ // separated by commas or by spaces, and a rule's argument may itself contain
139+ // either — Bash(git log --oneline, -n 5) is one rule — so separators count only
140+ // outside parentheses.
141+ func SplitRules(list string) []string {
142+ var (
143+ out []string
144+ cur strings.Builder
145+ depth int
146+ )
147+ flush := func() {
148+ if s := strings.TrimSpace(cur.String()); s != "" {
149+ out = append(out, s)
150+ }
151+ cur.Reset()
152+ }
153+ for _, r := range list {
154+ switch {
155+ case r == '(':
156+ depth++
157+ case r == ')' && depth > 0:
158+ depth--
159+ case depth == 0 && (r == ',' || unicode.IsSpace(r)):
160+ flush()
161+ continue
162+ }
163+ cur.WriteRune(r)
164+ }
165+ flush()
166+ return out
167+ }
168+
169+ // ParseRules parses every rule in the list, keeping the good ones. The errors
170+ // are for a warning: one rule that names a tool Codey does not have is no
171+ // reason to drop the others.
172+ func ParseRules(list []string) ([]Rule, []error) {
173+ var (
174+ rules []Rule
175+ errs []error
176+ )
177+ for _, s := range list {
178+ r, err := ParseRule(s)
179+ if err != nil {
180+ errs = append(errs, err)
181+ continue
182+ }
183+ rules = append(rules, r)
184+ }
185+ return rules, errs
186+ }
187+
188+ // ParseRule reads one rule: a tool name, optionally followed by an argument in
189+ // parentheses — Bash, Bash(git status), Bash(npm run test:*), Read(./src/**),
190+ // WebFetch(domain:example.com), mcp__github, mcp__github__create_issue.
191+ func ParseRule(s string) (Rule, error) {
192+ raw := strings.TrimSpace(s)
193+ name, spec := raw, ""
194+ if i := strings.IndexByte(raw, '('); i >= 0 {
195+ if !strings.HasSuffix(raw, ")") {
196+ return Rule{}, fmt.Errorf("rule %q: no closing parenthesis", raw)
197+ }
198+ name, spec = raw[:i], strings.TrimSpace(raw[i+1:len(raw)-1])
199+ }
200+ if name == "" {
201+ return Rule{}, fmt.Errorf("rule %q: no tool name", raw)
202+ }
203+ // An argument of * is the whole tool written longhand.
204+ if spec == "*" {
205+ spec = ""
206+ }
207+ r := Rule{Raw: raw, Specifier: spec}
208+
209+ switch {
210+ case strings.HasPrefix(name, mcpPrefix):
211+ if spec != "" {
212+ return Rule{}, fmt.Errorf("rule %q: an MCP rule takes no argument", raw)
213+ }
214+ // mcp__server__* is mcp__server: every tool the server has.
215+ name = strings.TrimSuffix(name, "__*")
216+ if name == strings.TrimSuffix(mcpPrefix, "__") || name == mcpPrefix {
217+ return Rule{}, fmt.Errorf("rule %q: no server named", raw)
218+ }
219+ r.Tools, r.mcp = []string{name}, true
220+ return r, nil
221+ }
222+
223+ if t, ok := ccTools[name]; ok {
224+ r.Tools, r.spec = t.whole, t.spec
225+ if spec != "" && t.scoped != nil {
226+ r.Tools = t.scoped
227+ }
228+ } else if kind, ok := codeyTools[name]; ok {
229+ r.Tools, r.spec = []string{name}, kind
230+ } else {
231+ return Rule{}, fmt.Errorf("rule %q: %w %q", raw, ErrUnknownTool, name)
232+ }
233+
234+ if spec == "" {
235+ return r, nil
236+ }
237+ switch r.spec {
238+ case specNone:
239+ return Rule{}, fmt.Errorf("rule %q: %s takes no argument", raw, name)
240+ case specDomain:
241+ host, ok := strings.CutPrefix(spec, "domain:")
242+ if !ok || host == "" {
243+ return Rule{}, fmt.Errorf("rule %q: expected %s(domain:example.com)", raw, name)
244+ }
245+ r.Specifier = strings.ToLower(host)
246+ }
247+ return r, nil
248+ }
249+
250+ // String is the rule as it was written.
251+ func (r Rule) String() string { return r.Raw }
252+
253+ // WholeTool reports whether the rule is about every call to its tools, which
254+ // for a deny rule means the tools need not be offered at all.
255+ func (r Rule) WholeTool() bool { return r.Specifier == "" }
256+
257+ // Covers reports whether the rule is about this tool at all, whatever the
258+ // arguments.
259+ func (r Rule) Covers(tool string) bool {
260+ for _, t := range r.Tools {
261+ if tool == t || (r.mcp && strings.HasPrefix(tool, t+"__")) {
262+ return true
263+ }
264+ }
265+ return false
266+ }
267+
268+ // Matches reports whether the rule covers the whole of this call. A shell
269+ // command matches only when every simple command in it does, and never when
270+ // the analysis cannot see what it runs. This is the reading an allow rule
271+ // wants.
272+ func (r Rule) Matches(tool string, input json.RawMessage) bool {
273+ if !r.Covers(tool) {
274+ return false
275+ }
276+ if r.WholeTool() {
277+ return true
278+ }
279+ switch r.spec {
280+ case specBash:
281+ cmds, exact := shellCommands(bashCommand(input))
282+ if !exact || len(cmds) == 0 {
283+ return false
284+ }
285+ for _, c := range cmds {
286+ if !r.bashAllows(c) {
287+ return false
288+ }
289+ }
290+ return true
291+ default:
292+ return r.matchesArg(tool, input)
293+ }
294+ }
295+
296+ // Touches reports whether any part of this call falls under the rule: one
297+ // simple command in a compound shell command is enough, as is anything that
298+ // can be read out of a command the analysis cannot otherwise see through. This
299+ // is the reading a deny rule wants.
300+ func (r Rule) Touches(tool string, input json.RawMessage) bool {
301+ if !r.Covers(tool) {
302+ return false
303+ }
304+ if r.WholeTool() {
305+ return true
306+ }
307+ if r.spec == specBash {
308+ for _, words := range denyCandidates(bashCommand(input), 0) {
309+ if r.bashWordsMatch(words) {
310+ return true
311+ }
312+ }
313+ return false
314+ }
315+ return r.matchesArg(tool, input)
316+ }
317+
318+ // matchesArg is the non-shell half of both readings: a path or a URL is one
319+ // thing, so there is nothing to be strict or lenient about.
320+ func (r Rule) matchesArg(tool string, input json.RawMessage) bool {
321+ var args struct {
322+ Path string `json:"path"`
323+ URL string `json:"url"`
324+ }
325+ json.Unmarshal(input, &args)
326+ switch r.spec {
327+ case specPath:
328+ p := args.Path
329+ if p == "" && (tool == "glob" || tool == "grep") {
330+ // The search tools default to the working root.
331+ p = "."
332+ }
333+ return p != "" && r.pathMatches(p)
334+ case specDomain:
335+ u, err := url.Parse(args.URL)
336+ if err != nil || u.Hostname() == "" {
337+ return false
338+ }
339+ host := strings.ToLower(u.Hostname())
340+ if suffix, ok := strings.CutPrefix(r.Specifier, "*."); ok {
341+ return strings.HasSuffix(host, "."+suffix)
342+ }
343+ return host == r.Specifier
344+ }
345+ return false
346+ }
347+
348+ // Verdict is what a Ruleset says about one call.
349+ type Verdict int
350+
351+ const (
352+ // Unmatched leaves the call to whatever decides otherwise — a prompt, or
353+ // a refusal where there is nobody to prompt.
354+ Unmatched Verdict = iota
355+ Allowed
356+ Denied
357+ )
358+
359+ // Ruleset is a list of allow rules and a list of deny rules, decided together.
360+ type Ruleset struct {
361+ Allow []Rule
362+ Deny []Rule
363+ }
364+
365+ // Empty reports whether there is nothing to decide with.
366+ func (s Ruleset) Empty() bool { return len(s.Allow) == 0 && len(s.Deny) == 0 }
367+
368+ // Check decides a call. Deny wins over allow, as in Claude Code: a rule saying
369+ // no is a statement about this repository, and no allow rule written for
370+ // convenience overrides it. The rule returned is the one that decided, for a
371+ // message.
372+ //
373+ // A compound shell command is allowed when each of its simple commands is
374+ // covered by some allow rule — not necessarily the same one — so
375+ // `git status && npm test` passes under Bash(git status) and Bash(npm test:*).
376+ // The rule returned then is the first that matched.
377+ func (s Ruleset) Check(tool string, input json.RawMessage) (Verdict, Rule) {
378+ for _, r := range s.Deny {
379+ if r.Touches(tool, input) {
380+ return Denied, r
381+ }
382+ }
383+ if tool != "bash" {
384+ for _, r := range s.Allow {
385+ if r.Matches(tool, input) {
386+ return Allowed, r
387+ }
388+ }
389+ return Unmatched, Rule{}
390+ }
391+
392+ cmds, exact := shellCommands(bashCommand(input))
393+ if !exact || len(cmds) == 0 {
394+ // What cannot be seen through is allowed only by a rule that allows
395+ // every command there is.
396+ for _, r := range s.Allow {
397+ if r.Covers(tool) && r.WholeTool() {
398+ return Allowed, r
399+ }
400+ }
401+ return Unmatched, Rule{}
402+ }
403+ var first Rule
404+ for i, c := range cmds {
405+ covered := false
406+ for _, r := range s.Allow {
407+ if r.Covers(tool) && (r.WholeTool() || r.bashAllows(c)) {
408+ if i == 0 {
409+ first = r
410+ }
411+ covered = true
412+ break
413+ }
414+ }
415+ if !covered {
416+ return Unmatched, Rule{}
417+ }
418+ }
419+ return Allowed, first
420+ }
421+
422+ // bashCommand pulls the command out of a bash call.
423+ func bashCommand(input json.RawMessage) string {
424+ var args struct {
425+ Command string `json:"command"`
426+ }
427+ json.Unmarshal(input, &args)
428+ return args.Command
429+ }
430+
431+ // shellCmd is one simple command, as words.
432+ type shellCmd struct {
433+ words []string
434+ // fixed[i] says words[i] is exactly what the shell will pass: no
435+ // expansion, no unquoted glob. Only fixed words can match a rule's own
436+ // words; anything else can only fall under its trailing wildcard.
437+ fixed []bool
438+ }
439+
440+ // bashAllows is the strict reading for one simple command.
441+ func (r Rule) bashAllows(c shellCmd) bool {
442+ want, prefix := r.bashPattern()
443+ if want == nil {
444+ // A rule with a wildcard in the middle is matched against the command
445+ // as one string, which only means anything when every word is fixed.
446+ for _, f := range c.fixed {
447+ if !f {
448+ return false
449+ }
450+ }
451+ return wildcardMatch(r.Specifier, strings.Join(c.words, " "))
452+ }
453+ if len(c.words) < len(want) || (!prefix && len(c.words) != len(want)) {
454+ return false
455+ }
456+ for i, w := range want {
457+ if !c.fixed[i] || c.words[i] != w {
458+ return false
459+ }
460+ }
461+ return true
462+ }
463+
464+ // bashWordsMatch is the lenient reading for one candidate: the words as
465+ // written, expansions and all, against the rule.
466+ func (r Rule) bashWordsMatch(words []string) bool {
467+ want, prefix := r.bashPattern()
468+ if want == nil {
469+ return wildcardMatch(r.Specifier, strings.Join(words, " "))
470+ }
471+ if len(words) < len(want) || (!prefix && len(words) != len(want)) {
472+ return false
473+ }
474+ for i, w := range want {
475+ if words[i] != w {
476+ return false
477+ }
478+ }
479+ return true
480+ }
481+
482+ // bashPattern reads a Bash rule's argument. `git status:*` and `git status *`
483+ // are a prefix, matched word by word, so Bash(ls:*) covers `ls -la` and not
484+ // `lsof`; anything without a wildcard is an exact command. A wildcard anywhere
485+ // else returns nil, and the argument is matched as a pattern over the whole
486+ // command line.
487+ func (r Rule) bashPattern() (words []string, prefix bool) {
488+ spec := r.Specifier
489+ switch {
490+ case strings.HasSuffix(spec, ":*"):
491+ spec, prefix = strings.TrimSuffix(spec, ":*"), true
492+ case strings.HasSuffix(spec, " *"):
493+ spec, prefix = strings.TrimSuffix(spec, " *"), true
494+ }
495+ if strings.Contains(spec, "*") {
496+ return nil, false
497+ }
498+ return strings.Fields(spec), prefix
499+ }
500+
501+ // wildcardMatch matches s against a pattern in which * stands for any run of
502+ // characters, and nothing else is special.
503+ func wildcardMatch(pattern, s string) bool {
504+ parts := strings.Split(pattern, "*")
505+ if len(parts) == 1 {
506+ return pattern == s
507+ }
508+ if !strings.HasPrefix(s, parts[0]) {
509+ return false
510+ }
511+ s = s[len(parts[0]):]
512+ for _, p := range parts[1 : len(parts)-1] {
513+ i := strings.Index(s, p)
514+ if i < 0 {
515+ return false
516+ }
517+ s = s[i+len(p):]
518+ }
519+ return strings.HasSuffix(s, parts[len(parts)-1])
520+ }
521+
522+ // shellCommands lists the simple commands in a script, and reports whether
523+ // that list is the whole truth about what it runs. It is not when the script
524+ // does not parse, substitutes a command, runs an inline interpreter script,
525+ // assigns a variable — PATH=. changes what every later word means — or writes
526+ // output to a file, which no command prefix says anything about.
527+ func shellCommands(script string) ([]shellCmd, bool) {
528+ f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
529+ if err != nil {
530+ return nil, false
531+ }
532+ exact := true
533+ var cmds []shellCmd
534+ syntax.Walk(f, func(node syntax.Node) bool {
535+ switch n := node.(type) {
536+ case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.DeclClause, *syntax.CoprocClause:
537+ exact = false
538+ case *syntax.Stmt:
539+ for _, rd := range n.Redirs {
540+ if writesFile(rd) {
541+ exact = false
542+ }
543+ }
544+ case *syntax.CallExpr:
545+ if len(n.Assigns) > 0 {
546+ exact = false
547+ }
548+ if len(n.Args) == 0 {
549+ return true
550+ }
551+ c := shellCmd{}
552+ for _, w := range n.Args {
553+ text, fixed := wordText(w)
554+ c.words = append(c.words, text)
555+ c.fixed = append(c.fixed, fixed)
556+ }
557+ if !c.fixed[0] {
558+ // A command name the shell computes is a command nobody
559+ // can name in a rule.
560+ exact = false
561+ }
562+ if interpreters[c.words[0]] && inlineScript(c.words[1:]) {
563+ exact = false
564+ }
565+ cmds = append(cmds, c)
566+ }
567+ return true
568+ })
569+ return cmds, exact
570+ }
571+
572+ // writesFile reports whether a redirection sends output somewhere other than
573+ // another descriptor or /dev/null.
574+ func writesFile(rd *syntax.Redirect) bool {
575+ target, fixed := wordText(rd.Word)
576+ switch rd.Op {
577+ case syntax.RdrOut, syntax.AppOut, syntax.RdrAll, syntax.AppAll, syntax.ClbOut, syntax.RdrInOut:
578+ case syntax.DplOut:
579+ // >&2 duplicates a descriptor; >&name, in bash, writes a file.
580+ if fixed && (target == "-" || strings.Trim(target, "0123456789") == "") {
581+ return false
582+ }
583+ default:
584+ return false
585+ }
586+ return !fixed || target != "/dev/null"
587+ }
588+
589+ // inlineScript is hasInlineScript for words already read out.
590+ func inlineScript(args []string) bool {
591+ for _, a := range args {
592+ if a == "-c" || a == "-e" || a == "--command" {
593+ return true
594+ }
595+ }
596+ return false
597+ }
598+
599+ // wordText renders a word the way the shell will see it after quote removal,
600+ // and reports whether that rendering is certain. A word with an expansion in
601+ // it is rendered as written, which is good enough for a deny rule to look at
602+ // and never good enough for an allow rule to trust.
603+ func wordText(w *syntax.Word) (string, bool) {
604+ var b strings.Builder
605+ fixed := true
606+ for i, part := range w.Parts {
607+ switch p := part.(type) {
608+ case *syntax.Lit:
609+ if strings.ContainsAny(p.Value, "*?[") || (i == 0 && strings.HasPrefix(p.Value, "~")) ||
610+ (strings.Contains(p.Value, "{") && strings.Contains(p.Value, "}") &&
611+ (strings.Contains(p.Value, ",") || strings.Contains(p.Value, ".."))) {
612+ // A glob, a home directory or a brace expansion: the shell
613+ // passes something other than what is written.
614+ fixed = false
615+ }
616+ b.WriteString(unescape(p.Value, false))
617+ case *syntax.SglQuoted:
618+ b.WriteString(p.Value)
619+ case *syntax.DblQuoted:
620+ for _, inner := range p.Parts {
621+ if lit, ok := inner.(*syntax.Lit); ok {
622+ b.WriteString(unescape(lit.Value, true))
623+ continue
624+ }
625+ fixed = false
626+ b.WriteString(source(inner))
627+ }
628+ default:
629+ fixed = false
630+ b.WriteString(source(part))
631+ }
632+ }
633+ return b.String(), fixed
634+ }
635+
636+ // unescape removes the backslashes the shell would. Outside quotes every
637+ // backslash escapes the next character; inside double quotes only a few
638+ // characters can be escaped and the backslash stays before anything else.
639+ func unescape(s string, quoted bool) string {
640+ if !strings.Contains(s, `\`) {
641+ return s
642+ }
643+ var b strings.Builder
644+ for i := 0; i < len(s); i++ {
645+ if s[i] != '\\' || i+1 == len(s) {
646+ b.WriteByte(s[i])
647+ continue
648+ }
649+ next := s[i+1]
650+ switch {
651+ case next == '\n':
652+ i++
653+ case !quoted || strings.IndexByte("$`\"\\", next) >= 0:
654+ b.WriteByte(next)
655+ i++
656+ default:
657+ b.WriteByte(s[i])
658+ }
659+ }
660+ return b.String()
661+ }
662+
663+ // source prints a node back as shell text.
664+ func source(n syntax.Node) string {
665+ var b strings.Builder
666+ syntax.NewPrinter().Print(&b, n)
667+ return b.String()
668+ }
669+
670+ // maxShellDepth bounds how far denyCandidates follows `bash -c` into `bash -c`.
671+ const maxShellDepth = 4
672+
673+ // denyCandidates is every reading of a script a deny rule should be tried
674+ // against: each simple command, each again with a wrapper like sudo or env
675+ // taken off the front and its path stripped to the program name, and the
676+ // script inside a `bash -c` or an eval. A script that does not parse is split
677+ // on the obvious separators instead, because being unparseable is no reason to
678+ // escape a rule that would have caught it.
679+ func denyCandidates(script string, depth int) [][]string {
680+ f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
681+ if err != nil {
682+ var out [][]string
683+ for _, piece := range strings.FieldsFunc(script, func(r rune) bool {
684+ return strings.ContainsRune(";&|\n()`", r)
685+ }) {
686+ if words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(piece, `"`, ""), "'", "")); len(words) > 0 {
687+ out = append(out, unwrapped(words)...)
688+ }
689+ }
690+ return out
691+ }
692+
693+ var out [][]string
694+ syntax.Walk(f, func(node syntax.Node) bool {
695+ call, ok := node.(*syntax.CallExpr)
696+ if !ok || len(call.Args) == 0 {
697+ return true
698+ }
699+ words := make([]string, 0, len(call.Args))
700+ for _, w := range call.Args {
701+ text, _ := wordText(w)
702+ words = append(words, text)
703+ }
704+ for _, cand := range unwrapped(words) {
705+ out = append(out, cand)
706+ if depth >= maxShellDepth {
707+ continue
708+ }
709+ if inner, ok := innerScript(cand); ok {
710+ out = append(out, denyCandidates(inner, depth+1)...)
711+ }
712+ }
713+ return true
714+ })
715+ return out
716+ }
717+
718+ // innerScript is the script a command runs as a string: the argument of
719+ // `sh -c`, or everything handed to eval.
720+ func innerScript(words []string) (string, bool) {
721+ switch words[0] {
722+ case "eval":
723+ return strings.Join(words[1:], " "), len(words) > 1
724+ case "bash", "sh", "zsh", "dash", "ksh":
725+ for i, w := range words[1:] {
726+ if w == "-c" && i+2 < len(words) {
727+ return words[i+2], true
728+ }
729+ }
730+ }
731+ return "", false
732+ }
733+
734+ // wrappers run the command that follows them, so that `sudo rm -rf x` and
735+ // `timeout 5 rm -rf x` are both read as rm.
736+ var wrappers = map[string]bool{
737+ "sudo": true, "doas": true, "command": true, "builtin": true, "exec": true,
738+ "nohup": true, "nice": true, "time": true, "env": true, "timeout": true,
739+ "xargs": true, "stdbuf": true, "ionice": true,
740+ }
741+
742+ // unwrapped is a command, then the same command each time a wrapper comes off
743+ // the front, each with a path on the program name reduced to the name itself:
744+ // /bin/rm is rm as far as a rule is concerned.
745+ func unwrapped(words []string) [][]string {
746+ var out [][]string
747+ for len(words) > 0 {
748+ if strings.Contains(words[0], "/") {
749+ words = append([]string{path.Base(words[0])}, words[1:]...)
750+ }
751+ out = append(out, words)
752+ if !wrappers[words[0]] {
753+ break
754+ }
755+ wrapper := words[0]
756+ words = words[1:]
757+ // Skip the wrapper's own options and assignments. The value of an
758+ // option given as a separate word, and timeout's duration, are taken
759+ // off too; a guess that takes off one word too many only means a rule
760+ // sees a shorter command, never that it sees nothing.
761+ for len(words) > 0 {
762+ w := words[0]
763+ switch {
764+ case strings.HasPrefix(w, "-"):
765+ words = words[1:]
766+ if (wrapper == "sudo" || wrapper == "doas") && (w == "-u" || w == "-g") && len(words) > 0 {
767+ words = words[1:]
768+ }
769+ if wrapper == "nice" && w == "-n" && len(words) > 0 {
770+ words = words[1:]
771+ }
772+ continue
773+ case wrapper == "env" && strings.Contains(w, "="):
774+ words = words[1:]
775+ continue
776+ case wrapper == "timeout" && len(w) > 0 && unicode.IsDigit(rune(w[0])):
777+ words = words[1:]
778+ }
779+ break
780+ }
781+ }
782+ return out
783+ }
784+
785+ // pathMatches reads a path pattern the way Claude Code does, which is the way
786+ // gitignore does: // starts an absolute path, ~/ the home directory, / and ./
787+ // the root, and a pattern with no slash in it matches a name at any depth. **
788+ // crosses directories and * does not. A pattern ending in a slash is a
789+ // directory and everything under it.
790+ func (r Rule) pathMatches(p string) bool {
791+ pattern := r.Specifier
792+ // gitignore anchors a pattern with a slash anywhere but at its end; one
793+ // without is a name, matched at any depth.
794+ anchored := strings.Contains(strings.TrimSuffix(pattern, "/"), "/")
795+ if strings.HasSuffix(pattern, "/") {
796+ pattern += "**"
797+ }
798+
799+ target := filepath.ToSlash(filepath.Clean(p))
800+ if !filepath.IsAbs(p) && r.Root != "" {
801+ target = filepath.ToSlash(filepath.Join(r.Root, p))
802+ }
803+ under := func(rel string) string {
804+ if r.Root == "" {
805+ return rel
806+ }
807+ return filepath.ToSlash(filepath.Clean(r.Root)) + "/" + rel
808+ }
809+
810+ var full string
811+ switch {
812+ case strings.HasPrefix(pattern, "//"):
813+ full = pattern[1:]
814+ case strings.HasPrefix(pattern, "~/"):
815+ home := r.Home
816+ if home == "" {
817+ home, _ = os.UserHomeDir()
818+ }
819+ full = filepath.ToSlash(filepath.Clean(home)) + "/" + pattern[2:]
820+ case strings.HasPrefix(pattern, "./"):
821+ full = under(pattern[2:])
822+ case strings.HasPrefix(pattern, "/"):
823+ full = under(pattern[1:])
824+ case !anchored:
825+ full = under("**/" + pattern)
826+ default:
827+ full = under(pattern)
828+ }
829+ return globMatch(segments(full), segments(target))
830+ }
831+
832+ // segments splits a slash path into its parts, dropping the empty and "."
833+ // ones a join or a leading slash leaves behind.
834+ func segments(p string) []string {
835+ var out []string
836+ for _, s := range strings.Split(p, "/") {
837+ if s != "" && s != "." {
838+ out = append(out, s)
839+ }
840+ }
841+ return out
842+ }
843+
844+ // globMatch matches path segments against pattern segments, with ** standing
845+ // for any number of segments, none included.
846+ func globMatch(pat, segs []string) bool {
847+ for len(pat) > 0 {
848+ if pat[0] == "**" {
849+ for i := 0; i <= len(segs); i++ {
850+ if globMatch(pat[1:], segs[i:]) {
851+ return true
852+ }
853+ }
854+ return false
855+ }
856+ if len(segs) == 0 {
857+ return false
858+ }
859+ if ok, err := path.Match(pat[0], segs[0]); err != nil || !ok {
860+ return false
861+ }
862+ pat, segs = pat[1:], segs[1:]
863+ }
864+ return len(segs) == 0
865+ }
+425−0internal/approval/rules_test.goaddedView File
@@ -0,0 +1,425 @@
1+ package approval
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "slices"
8+ "strings"
9+ "testing"
10+ )
11+
12+ func bashInput(cmd string) json.RawMessage {
13+ b, _ := json.Marshal(map[string]string{"command": cmd})
14+ return b
15+ }
16+
17+ func pathInput(p string) json.RawMessage {
18+ b, _ := json.Marshal(map[string]string{"path": p})
19+ return b
20+ }
21+
22+ func mustRule(t *testing.T, s string) Rule {
23+ t.Helper()
24+ r, err := ParseRule(s)
25+ if err != nil {
26+ t.Fatalf("ParseRule(%q): %v", s, err)
27+ }
28+ return r
29+ }
30+
31+ func TestSplitRulesKeepsParenthesesWhole(t *testing.T) {
32+ got := SplitRules(`Read,Edit Bash(git log --oneline, -n 5) "x" Bash(npm run test:*),mcp__github`)
33+ want := []string{"Read", "Edit", "Bash(git log --oneline, -n 5)", `"x"`, "Bash(npm run test:*)", "mcp__github"}
34+ if !slices.Equal(got, want) {
35+ t.Errorf("SplitRules = %q, want %q", got, want)
36+ }
37+ if got := SplitRules(" , "); len(got) != 0 {
38+ t.Errorf("an empty list should split to nothing, got %q", got)
39+ }
40+ }
41+
42+ func TestParseRuleMapsClaudeCodeNames(t *testing.T) {
43+ cases := []struct {
44+ rule string
45+ tools []string
46+ }{
47+ {"Bash", []string{"bash"}},
48+ {"Read", []string{"read_file"}},
49+ // A path-scoped Read rule reaches the search tools too, as Claude
50+ // Code's does.
51+ {"Read(./secrets/**)", []string{"read_file", "glob", "grep"}},
52+ {"Edit", []string{"edit_file", "write_file"}},
53+ {"MultiEdit", []string{"edit_file"}},
54+ {"Write", []string{"write_file"}},
55+ {"Glob", []string{"glob"}},
56+ {"Grep", []string{"grep"}},
57+ {"WebFetch", []string{"web_fetch"}},
58+ {"WebSearch", []string{"web_search"}},
59+ {"TodoWrite", []string{"todo_write"}},
60+ {"Task", []string{"spawn_agent"}},
61+ {"bash(git status)", []string{"bash"}},
62+ {"process_kill", []string{"process_kill"}},
63+ {"mcp__github", []string{"mcp__github"}},
64+ {"mcp__github__*", []string{"mcp__github"}},
65+ {"mcp__github__create_issue", []string{"mcp__github__create_issue"}},
66+ }
67+ for _, c := range cases {
68+ r := mustRule(t, c.rule)
69+ if !slices.Equal(r.Tools, c.tools) {
70+ t.Errorf("%s covers %q, want %q", c.rule, r.Tools, c.tools)
71+ }
72+ if r.String() != c.rule {
73+ t.Errorf("String() = %q, want the rule as written", r.String())
74+ }
75+ }
76+ }
77+
78+ func TestParseRuleRejectsWhatItCannotRead(t *testing.T) {
79+ for _, s := range []string{"", "Frobnicate", "NotebookEdit", "Bash(git status", "Glob(*.go)",
80+ "WebFetch(example.com)", "WebFetch(domain:)", "mcp__", "mcp__srv(x)", "(x)"} {
81+ if _, err := ParseRule(s); err == nil {
82+ t.Errorf("ParseRule(%q) should fail", s)
83+ }
84+ }
85+ _, err := ParseRule("Frobnicate")
86+ if !errors.Is(err, ErrUnknownTool) {
87+ t.Errorf("an unknown tool should be ErrUnknownTool, got %v", err)
88+ }
89+ }
90+
91+ func TestParseRulesKeepsTheGoodOnes(t *testing.T) {
92+ rules, errs := ParseRules([]string{"Bash", "Nope", "Read"})
93+ if len(rules) != 2 || len(errs) != 1 {
94+ t.Fatalf("got %d rules and %d errors, want 2 and 1", len(rules), len(errs))
95+ }
96+ }
97+
98+ func TestStarIsTheWholeTool(t *testing.T) {
99+ r := mustRule(t, "Bash(*)")
100+ if !r.WholeTool() {
101+ t.Error("Bash(*) should be the whole tool")
102+ }
103+ if !r.Matches("bash", bashInput("echo $(rm -rf ~)")) {
104+ t.Error("a whole-tool rule allows even what the analysis cannot read")
105+ }
106+ }
107+
108+ func TestBashExactRule(t *testing.T) {
109+ r := mustRule(t, "Bash(git status)")
110+ for cmd, want := range map[string]bool{
111+ "git status": true,
112+ " git status ": true,
113+ "git 'status'": true,
114+ "git status --short": false,
115+ "git statusx": false,
116+ "git": false,
117+ "git status; rm -rf ~": false,
118+ } {
119+ if got := r.Matches("bash", bashInput(cmd)); got != want {
120+ t.Errorf("Bash(git status) matches %q = %v, want %v", cmd, got, want)
121+ }
122+ }
123+ if r.Matches("read_file", bashInput("git status")) {
124+ t.Error("a Bash rule says nothing about another tool")
125+ }
126+ }
127+
128+ func TestBashPrefixRuleIsWordAware(t *testing.T) {
129+ for _, spec := range []string{"Bash(npm run test:*)", "Bash(npm run test *)"} {
130+ r := mustRule(t, spec)
131+ for cmd, want := range map[string]bool{
132+ "npm run test": true,
133+ "npm run test -- --watch": true,
134+ "npm run test:unit": false,
135+ "npm run testing": false,
136+ "npm run build": false,
137+ "npm run test && npm publish": false,
138+ "cd web && npm run test": false,
139+ } {
140+ if got := r.Matches("bash", bashInput(cmd)); got != want {
141+ t.Errorf("%s matches %q = %v, want %v", spec, cmd, got, want)
142+ }
143+ }
144+ }
145+ ls := mustRule(t, "Bash(ls:*)")
146+ if ls.Matches("bash", bashInput("lsof -i")) {
147+ t.Error("Bash(ls:*) must not match lsof")
148+ }
149+ if !ls.Matches("bash", bashInput("ls -la src")) {
150+ t.Error("Bash(ls:*) should match ls -la")
151+ }
152+ }
153+
154+ func TestBashCompoundCommandsNeedEveryPartAllowed(t *testing.T) {
155+ r := mustRule(t, "Bash(git status:*)")
156+ for _, cmd := range []string{
157+ "git status; rm -rf ~",
158+ "git status && rm -rf ~",
159+ "git status || rm -rf ~",
160+ "git status | sh",
161+ "rm -rf ~ & git status",
162+ "if true; then rm -rf ~; fi; git status",
163+ "(git status; rm -rf ~)",
164+ "git status\nrm -rf ~",
165+ "f() { rm -rf ~; }; git status",
166+ } {
167+ if r.Matches("bash", bashInput(cmd)) {
168+ t.Errorf("Bash(git status:*) must not match %q", cmd)
169+ }
170+ if v, _ := (Ruleset{Allow: []Rule{r}}).Check("bash", bashInput(cmd)); v == Allowed {
171+ t.Errorf("the ruleset must not allow %q", cmd)
172+ }
173+ }
174+ if !r.Matches("bash", bashInput("git status && git status -s")) {
175+ t.Error("a compound of allowed commands is allowed")
176+ }
177+ }
178+
179+ func TestBashOpaqueCommandsAreNeverAllowedByAPrefix(t *testing.T) {
180+ r := mustRule(t, "Bash(git:*)")
181+ for _, cmd := range []string{
182+ "git log $(rm -rf ~)",
183+ "git log `rm -rf ~`",
184+ "git diff <(rm -rf ~)",
185+ "$GIT status",
186+ "PATH=/tmp/evil git status",
187+ "export PATH=/tmp; git status",
188+ "git status > ~/.bashrc",
189+ "git status >> notes.txt",
190+ "git status &> out",
191+ "git status >&out",
192+ "git status $(",
193+ "bash -c 'git status'",
194+ } {
195+ if r.Matches("bash", bashInput(cmd)) {
196+ t.Errorf("Bash(git:*) must not match %q", cmd)
197+ }
198+ }
199+ for _, cmd := range []string{
200+ "git status 2>&1",
201+ "git status > /dev/null",
202+ "git status 2>/dev/null",
203+ "git log -n 5 \"$BRANCH\"",
204+ "git log -- *.go",
205+ } {
206+ if !r.Matches("bash", bashInput(cmd)) {
207+ t.Errorf("Bash(git:*) should match %q", cmd)
208+ }
209+ }
210+ }
211+
212+ func TestBashExpansionsCannotStandInForARuleWord(t *testing.T) {
213+ r := mustRule(t, "Bash(npm run test:*)")
214+ for _, cmd := range []string{"npm run $TARGET", "npm run t*", "npm run {test,deploy}", "npm ~ test"} {
215+ if r.Matches("bash", bashInput(cmd)) {
216+ t.Errorf("%q has an expansion where the rule has a word, and must not match", cmd)
217+ }
218+ }
219+ if !r.Matches("bash", bashInput("npm run test -- $EXTRA")) {
220+ t.Error("an expansion in the wildcard tail is fine")
221+ }
222+ }
223+
224+ func TestBashMiddleWildcard(t *testing.T) {
225+ r := mustRule(t, "Bash(git * main)")
226+ if !r.Matches("bash", bashInput("git push origin main")) {
227+ t.Error("Bash(git * main) should match git push origin main")
228+ }
229+ if r.Matches("bash", bashInput("git push origin dev")) {
230+ t.Error("Bash(git * main) must not match a different branch")
231+ }
232+ }
233+
234+ func TestRulesetAllowsACompoundCoveredByDifferentRules(t *testing.T) {
235+ set := Ruleset{Allow: []Rule{mustRule(t, "Bash(git status)"), mustRule(t, "Bash(npm test:*)")}}
236+ if v, r := set.Check("bash", bashInput("git status && npm test -- -run X")); v != Allowed || r.Raw != "Bash(git status)" {
237+ t.Errorf("got %v by %q, want Allowed by the first rule", v, r.Raw)
238+ }
239+ if v, _ := set.Check("bash", bashInput("git status && npm publish")); v != Unmatched {
240+ t.Errorf("one uncovered command leaves the call unmatched, got %v", v)
241+ }
242+ }
243+
244+ func TestDenyTouchesAnyPart(t *testing.T) {
245+ r := mustRule(t, "Bash(rm:*)")
246+ for _, cmd := range []string{
247+ "rm -rf build",
248+ "git status; rm -rf ~",
249+ "make && rm -rf ~",
250+ "echo $(rm -rf ~)",
251+ "git log `rm x`",
252+ "cat <(rm x)",
253+ "sudo rm -rf /",
254+ "sudo -u root rm -rf /",
255+ "env FOO=1 rm x",
256+ "FOO=1 rm x",
257+ "timeout 5 rm x",
258+ "nice -n 10 rm x",
259+ "xargs rm < list",
260+ "/bin/rm -rf x",
261+ `r\m -rf x`,
262+ `"rm" -rf x`,
263+ "bash -c 'rm -rf ~'",
264+ "sh -c \"cd /tmp && rm -rf x\"",
265+ "eval rm -rf x",
266+ "bash -c 'bash -c \"rm x\"'",
267+ "rm -rf x $(",
268+ "if true; then rm x; fi",
269+ } {
270+ if !r.Touches("bash", bashInput(cmd)) {
271+ t.Errorf("Bash(rm:*) should touch %q", cmd)
272+ }
273+ }
274+ for _, cmd := range []string{"git rm --cached x", "echo rm", "rmdir x", "ls"} {
275+ if r.Touches("bash", bashInput(cmd)) {
276+ t.Errorf("Bash(rm:*) must not touch %q", cmd)
277+ }
278+ }
279+ }
280+
281+ func TestDenyWinsOverAllow(t *testing.T) {
282+ set := Ruleset{
283+ Allow: []Rule{mustRule(t, "Bash")},
284+ Deny: []Rule{mustRule(t, "Bash(git push:*)")},
285+ }
286+ if v, r := set.Check("bash", bashInput("git commit -m x && git push origin main")); v != Denied || r.Raw != "Bash(git push:*)" {
287+ t.Errorf("got %v by %q, want Denied by the push rule", v, r.Raw)
288+ }
289+ if v, _ := set.Check("bash", bashInput("git commit -m x")); v != Allowed {
290+ t.Errorf("the rest of Bash stays allowed, got %v", v)
291+ }
292+ }
293+
294+ func TestPathRules(t *testing.T) {
295+ root, home := "/work/repo", "/home/me"
296+ cases := []struct {
297+ rule string
298+ path string
299+ want bool
300+ }{
301+ {"Read(./secrets/**)", "secrets/a.txt", true},
302+ {"Read(./secrets/**)", "/work/repo/secrets/deep/a.txt", true},
303+ {"Read(./secrets/**)", "src/secrets/a.txt", false},
304+ {"Read(/secrets/**)", "secrets/a.txt", true},
305+ {"Read(secrets/**)", "secrets/a.txt", true},
306+ {"Read(secrets/**)", "src/secrets/a.txt", false},
307+ // No slash: a name at any depth, as in gitignore.
308+ {"Read(.env)", ".env", true},
309+ {"Read(.env)", "config/.env", true},
310+ {"Read(.env)", ".envrc", false},
311+ {"Read(*.pem)", "certs/server.pem", true},
312+ {"Read(secrets/)", "a/secrets/b.txt", true},
313+ {"Read(//etc/**)", "/etc/passwd", true},
314+ {"Read(//etc/**)", "etc/passwd", false},
315+ {"Read(~/.ssh/**)", "/home/me/.ssh/id_ed25519", true},
316+ {"Edit(./src/*.go)", "src/main.go", true},
317+ {"Edit(./src/*.go)", "src/sub/main.go", false},
318+ {"Edit(./src/**/*.go)", "src/sub/main.go", true},
319+ {"Edit(./src/**/*.go)", "src/main.go", true},
320+ {"Edit(./docs/**)", "../repo/docs/x.md", true},
321+ {"Edit(./docs/**)", "docs/../src/x.go", false},
322+ }
323+ for _, c := range cases {
324+ r := mustRule(t, c.rule)
325+ r.Root, r.Home = root, home
326+ tool := r.Tools[0]
327+ if got := r.Matches(tool, pathInput(c.path)); got != c.want {
328+ t.Errorf("%s on %q = %v, want %v", c.rule, c.path, got, c.want)
329+ }
330+ }
331+ }
332+
333+ func TestPathRulesReachEveryToolTheyName(t *testing.T) {
334+ edit := mustRule(t, "Edit(./gen/**)")
335+ edit.Root = "/r"
336+ for _, tool := range []string{"edit_file", "write_file"} {
337+ if !edit.Matches(tool, pathInput("gen/x.go")) {
338+ t.Errorf("Edit(...) should cover %s", tool)
339+ }
340+ }
341+ read := mustRule(t, "Read(./secrets/**)")
342+ read.Root = "/r"
343+ if !read.Touches("grep", pathInput("secrets")) && !read.Touches("grep", pathInput("secrets/x")) {
344+ t.Error("a Read path rule should reach grep")
345+ }
346+ if read.Touches("bash", bashInput("cat secrets/x")) {
347+ t.Error("a Read rule says nothing about bash")
348+ }
349+ }
350+
351+ func TestDomainRules(t *testing.T) {
352+ in := func(u string) json.RawMessage {
353+ b, _ := json.Marshal(map[string]string{"url": u})
354+ return b
355+ }
356+ r := mustRule(t, "WebFetch(domain:Example.com)")
357+ if !r.Matches("web_fetch", in("https://example.com/docs")) {
358+ t.Error("the domain itself should match, case-insensitively")
359+ }
360+ if r.Matches("web_fetch", in("https://evil-example.com/")) || r.Matches("web_fetch", in("https://api.example.com/")) {
361+ t.Error("a domain rule is exact unless it says *.")
362+ }
363+ sub := mustRule(t, "WebFetch(domain:*.example.com)")
364+ if !sub.Matches("web_fetch", in("https://api.example.com/x")) || sub.Matches("web_fetch", in("https://example.com/")) {
365+ t.Error("*.example.com is the subdomains")
366+ }
367+ if r.Matches("web_fetch", in("not a url")) {
368+ t.Error("an unreadable URL matches nothing")
369+ }
370+ }
371+
372+ func TestMCPRules(t *testing.T) {
373+ server := mustRule(t, "mcp__github")
374+ for tool, want := range map[string]bool{
375+ "mcp__github__create_issue": true,
376+ "mcp__github": true,
377+ "mcp__githubber__x": false,
378+ "mcp__gitlab__x": false,
379+ } {
380+ if got := server.Covers(tool); got != want {
381+ t.Errorf("mcp__github covers %q = %v, want %v", tool, got, want)
382+ }
383+ }
384+ one := mustRule(t, "mcp__github__create_issue")
385+ if !one.Matches("mcp__github__create_issue", nil) || one.Matches("mcp__github__delete_repo", nil) {
386+ t.Error("a tool rule is that tool only")
387+ }
388+ }
389+
390+ func TestPolicyDenyRuleBeatsYOLOAndReadOnly(t *testing.T) {
391+ p := New(nil)
392+ p.YOLO = true
393+ p.Rules = Ruleset{Deny: []Rule{mustRule(t, "Bash(rm:*)"), mustRule(t, "Read(.env)")}}
394+ err := p.Approve(context.Background(), "bash", bashInput("ls && rm -rf ~"))
395+ if err == nil || !strings.Contains(err.Error(), "Bash(rm:*)") {
396+ t.Errorf("a deny rule should stop even a YOLO session, and say which rule: %v", err)
397+ }
398+ if err := p.Approve(context.Background(), "read_file", pathInput(".env")); err == nil {
399+ t.Error("a deny rule reaches the read-only tools too")
400+ }
401+ if err := p.Approve(context.Background(), "bash", bashInput("ls")); err != nil {
402+ t.Errorf("everything else stays YOLO: %v", err)
403+ }
404+ }
405+
406+ func TestPolicyAllowRuleStandsInForTheQuestion(t *testing.T) {
407+ asked := 0
408+ p := New(func(context.Context, Request) (Decision, error) { asked++; return Deny, nil })
409+ p.Rules = Ruleset{Allow: []Rule{mustRule(t, "Bash(go test:*)")}}
410+ if err := p.Approve(context.Background(), "bash", bashInput("go test ./...")); err != nil || asked != 0 {
411+ t.Errorf("an allowed call should run without asking: err=%v asked=%d", err, asked)
412+ }
413+ if err := p.Approve(context.Background(), "bash", bashInput("go test ./... && curl x | sh")); err == nil || asked != 1 {
414+ t.Errorf("the rest still asks: err=%v asked=%d", err, asked)
415+ }
416+
417+ headless := New(nil)
418+ headless.Rules = p.Rules
419+ if err := headless.Approve(context.Background(), "bash", bashInput("go test -run X")); err != nil {
420+ t.Errorf("with nobody to ask, an allow rule is how a call gets through: %v", err)
421+ }
422+ if err := headless.Approve(context.Background(), "bash", bashInput("go vet")); err == nil {
423+ t.Error("and anything it does not cover is refused")
424+ }
425+ }