package approval // Permission rules in Claude Code's syntax. // // A script written for `claude -p` says what the agent may do with // --allowedTools and --disallowedTools, and a repository says the same thing in // .claude/settings.json under permissions.allow and permissions.deny. Both are // lists of rules in one grammar, and this file is the one reading of it, so // that a rule means the same thing wherever Codey found it. // // The API, for anything that has a list of rule strings in hand: // // rule, err := approval.ParseRule("Bash(npm run test:*)") // rule.Matches("bash", input) // the rule covers the whole call // rule.Touches("bash", input) // the rule covers some part of the call // // set := approval.Ruleset{Allow: allowRules, Deny: denyRules} // verdict, rule := set.Check("bash", input) // Allowed, Denied or Unmatched // policy.Rules = set // and the policy consults it // // SplitRules cuts a flag value or a settings entry into rule strings, and // ParseRules parses a list of them, keeping the good ones and reporting the // rest. An error from ParseRule is a reason to warn, not to stop: a rule // naming a tool Codey does not have is a rule about nothing here. // // Two readings of "matches" exist because allowing and denying need opposite // kinds of caution. A compound shell command is allowed only when every // simple command in it is covered by some allow rule, so `git status; rm -rf // ~` does not ride in on Bash(git status:*). It is denied when any one of them // hits a deny rule. A command the shell analysis cannot see through — command // substitution, an inline interpreter script, a parse error — is never allowed // by a rule, and is denied if anything that can be read out of it matches. // // Deny rules are a guard rail, not a sandbox, for the reason shell.go gives: // a determined command can always be spelled so that no string match sees it. // They catch the command the model would ordinarily write. import ( "encoding/json" "errors" "fmt" "net/url" "os" "path" "path/filepath" "strings" "unicode" "mvdan.cc/sh/v3/syntax" ) // ErrUnknownTool is wrapped by ParseRule when a rule names a tool Codey has no // counterpart for. Callers warn and carry on. var ErrUnknownTool = errors.New("unknown tool") // specKind is what the text inside a rule's parentheses is about. type specKind int const ( specNone specKind = iota // the rule takes no argument specBash // a command, or a command prefix specPath // a gitignore-style path pattern specDomain // domain:host, for web_fetch ) // ccTool is how one Claude Code tool name lands in Codey. type ccTool struct { // whole is what the bare name covers; scoped is what a rule with an // argument covers. They differ for Read, whose path rules Claude Code // applies to its search tools as well, and they are the same elsewhere. whole, scoped []string spec specKind } // ccTools maps Claude Code's tool names onto Codey's. // // Edit covers write_file as well as edit_file because Claude Code says its // Edit rules apply to every built-in tool that edits files, and Write is one; // a script that disallows Edit wants no edits, whichever tool would make them. // MultiEdit is Claude Code's several-replacements edit, which edit_file is. var ccTools = map[string]ccTool{ "Bash": {whole: []string{"bash"}, spec: specBash}, "Read": {whole: []string{"read_file"}, scoped: []string{"read_file", "glob", "grep"}, spec: specPath}, "Edit": {whole: []string{"edit_file", "write_file"}, spec: specPath}, "MultiEdit": {whole: []string{"edit_file"}, spec: specPath}, "Write": {whole: []string{"write_file"}, spec: specPath}, "Glob": {whole: []string{"glob"}}, "Grep": {whole: []string{"grep"}}, "WebFetch": {whole: []string{"web_fetch"}, spec: specDomain}, "WebSearch": {whole: []string{"web_search"}}, "TodoWrite": {whole: []string{"todo_write"}}, "Task": {whole: []string{"spawn_agent"}}, "Agent": {whole: []string{"spawn_agent"}}, "BashOutput": {whole: []string{"process_output"}}, "KillShell": {whole: []string{"process_kill"}}, "KillBash": {whole: []string{"process_kill"}}, "Skill": {whole: []string{"skill"}}, "AskUserQuestion": {whole: []string{"ask_user"}}, } // codeyTools are Codey's own names, accepted as they are, so a rule can name a // tool Claude Code has no word for. The ones that take an argument take the // same argument as their Claude Code counterpart. var codeyTools = map[string]specKind{ "bash": specBash, "read_file": specPath, "edit_file": specPath, "write_file": specPath, "glob": specNone, "grep": specNone, "web_fetch": specDomain, "web_search": specNone, "todo_write": specNone, "process_output": specNone, "process_kill": specNone, "skill": specNone, "ask_user": specNone, "context_status": specNone, "spawn_agent": specNone, "agent_wait": specNone, "lsp": specNone, } // mcpPrefix is how an MCP tool's registered name begins, here as in Claude Code. const mcpPrefix = "mcp__" // Rule is one parsed permission rule. type Rule struct { // Raw is the rule as it was written, for messages. Raw string // Tools are the Codey tool names the rule is about. For an MCP rule it // holds the one name, which is a whole server when it has no tool part. Tools []string // Specifier is the text inside the parentheses, empty for a rule about the // whole tool. Specifier string // Root is the directory relative path patterns are read against — the // working root for a flag, the project for a settings file. Empty means // paths are compared as the tool call gave them. Root string // Home is what a leading ~/ in a path pattern stands for. Empty means the // current user's home directory. Home string spec specKind mcp bool } // SplitRules cuts a list of rules into its items. Claude Code accepts them // separated by commas or by spaces, and a rule's argument may itself contain // either — Bash(git log --oneline, -n 5) is one rule — so separators count only // outside parentheses. func SplitRules(list string) []string { var ( out []string cur strings.Builder depth int ) flush := func() { if s := strings.TrimSpace(cur.String()); s != "" { out = append(out, s) } cur.Reset() } for _, r := range list { switch { case r == '(': depth++ case r == ')' && depth > 0: depth-- case depth == 0 && (r == ',' || unicode.IsSpace(r)): flush() continue } cur.WriteRune(r) } flush() return out } // ParseRules parses every rule in the list, keeping the good ones. The errors // are for a warning: one rule that names a tool Codey does not have is no // reason to drop the others. func ParseRules(list []string) ([]Rule, []error) { var ( rules []Rule errs []error ) for _, s := range list { r, err := ParseRule(s) if err != nil { errs = append(errs, err) continue } rules = append(rules, r) } return rules, errs } // ParseRule reads one rule: a tool name, optionally followed by an argument in // parentheses — Bash, Bash(git status), Bash(npm run test:*), Read(./src/**), // WebFetch(domain:example.com), mcp__github, mcp__github__create_issue. func ParseRule(s string) (Rule, error) { raw := strings.TrimSpace(s) name, spec := raw, "" if i := strings.IndexByte(raw, '('); i >= 0 { if !strings.HasSuffix(raw, ")") { return Rule{}, fmt.Errorf("rule %q: no closing parenthesis", raw) } name, spec = raw[:i], strings.TrimSpace(raw[i+1:len(raw)-1]) } if name == "" { return Rule{}, fmt.Errorf("rule %q: no tool name", raw) } // An argument of * is the whole tool written longhand. if spec == "*" { spec = "" } r := Rule{Raw: raw, Specifier: spec} switch { case strings.HasPrefix(name, mcpPrefix): if spec != "" { return Rule{}, fmt.Errorf("rule %q: an MCP rule takes no argument", raw) } // mcp__server__* is mcp__server: every tool the server has. name = strings.TrimSuffix(name, "__*") if name == strings.TrimSuffix(mcpPrefix, "__") || name == mcpPrefix { return Rule{}, fmt.Errorf("rule %q: no server named", raw) } r.Tools, r.mcp = []string{name}, true return r, nil } if t, ok := ccTools[name]; ok { r.Tools, r.spec = t.whole, t.spec if spec != "" && t.scoped != nil { r.Tools = t.scoped } } else if kind, ok := codeyTools[name]; ok { r.Tools, r.spec = []string{name}, kind } else { return Rule{}, fmt.Errorf("rule %q: %w %q", raw, ErrUnknownTool, name) } if spec == "" { return r, nil } switch r.spec { case specNone: return Rule{}, fmt.Errorf("rule %q: %s takes no argument", raw, name) case specDomain: host, ok := strings.CutPrefix(spec, "domain:") if !ok || host == "" { return Rule{}, fmt.Errorf("rule %q: expected %s(domain:example.com)", raw, name) } r.Specifier = strings.ToLower(host) } return r, nil } // String is the rule as it was written. func (r Rule) String() string { return r.Raw } // WholeTool reports whether the rule is about every call to its tools, which // for a deny rule means the tools need not be offered at all. func (r Rule) WholeTool() bool { return r.Specifier == "" } // Covers reports whether the rule is about this tool at all, whatever the // arguments. func (r Rule) Covers(tool string) bool { for _, t := range r.Tools { if tool == t || (r.mcp && strings.HasPrefix(tool, t+"__")) { return true } } return false } // Matches reports whether the rule covers the whole of this call. A shell // command matches only when every simple command in it does, and never when // the analysis cannot see what it runs. This is the reading an allow rule // wants. func (r Rule) Matches(tool string, input json.RawMessage) bool { if !r.Covers(tool) { return false } if r.WholeTool() { return true } switch r.spec { case specBash: cmds, exact := shellCommands(bashCommand(input)) if !exact || len(cmds) == 0 { return false } for _, c := range cmds { if !r.bashAllows(c) { return false } } return true default: return r.matchesArg(tool, input) } } // Touches reports whether any part of this call falls under the rule: one // simple command in a compound shell command is enough, as is anything that // can be read out of a command the analysis cannot otherwise see through. This // is the reading a deny rule wants. func (r Rule) Touches(tool string, input json.RawMessage) bool { if !r.Covers(tool) { return false } if r.WholeTool() { return true } if r.spec == specBash { for _, words := range denyCandidates(bashCommand(input), 0) { if r.bashWordsMatch(words) { return true } } return false } return r.matchesArg(tool, input) } // matchesArg is the non-shell half of both readings: a path or a URL is one // thing, so there is nothing to be strict or lenient about. func (r Rule) matchesArg(tool string, input json.RawMessage) bool { var args struct { Path string `json:"path"` URL string `json:"url"` } json.Unmarshal(input, &args) switch r.spec { case specPath: p := args.Path if p == "" && (tool == "glob" || tool == "grep") { // The search tools default to the working root. p = "." } return p != "" && r.pathMatches(p) case specDomain: u, err := url.Parse(args.URL) if err != nil || u.Hostname() == "" { return false } host := strings.ToLower(u.Hostname()) if suffix, ok := strings.CutPrefix(r.Specifier, "*."); ok { return strings.HasSuffix(host, "."+suffix) } return host == r.Specifier } return false } // Verdict is what a Ruleset says about one call. type Verdict int const ( // Unmatched leaves the call to whatever decides otherwise — a prompt, or // a refusal where there is nobody to prompt. Unmatched Verdict = iota Allowed Denied ) // Ruleset is a list of allow rules and a list of deny rules, decided together. type Ruleset struct { Allow []Rule Deny []Rule } // Empty reports whether there is nothing to decide with. func (s Ruleset) Empty() bool { return len(s.Allow) == 0 && len(s.Deny) == 0 } // Check decides a call. Deny wins over allow, as in Claude Code: a rule saying // no is a statement about this repository, and no allow rule written for // convenience overrides it. The rule returned is the one that decided, for a // message. // // A compound shell command is allowed when each of its simple commands is // covered by some allow rule — not necessarily the same one — so // `git status && npm test` passes under Bash(git status) and Bash(npm test:*). // The rule returned then is the first that matched. func (s Ruleset) Check(tool string, input json.RawMessage) (Verdict, Rule) { for _, r := range s.Deny { if r.Touches(tool, input) { return Denied, r } } if tool != "bash" { for _, r := range s.Allow { if r.Matches(tool, input) { return Allowed, r } } return Unmatched, Rule{} } cmds, exact := shellCommands(bashCommand(input)) if !exact || len(cmds) == 0 { // What cannot be seen through is allowed only by a rule that allows // every command there is. for _, r := range s.Allow { if r.Covers(tool) && r.WholeTool() { return Allowed, r } } return Unmatched, Rule{} } var first Rule for i, c := range cmds { covered := false for _, r := range s.Allow { if r.Covers(tool) && (r.WholeTool() || r.bashAllows(c)) { if i == 0 { first = r } covered = true break } } if !covered { return Unmatched, Rule{} } } return Allowed, first } // bashCommand pulls the command out of a bash call. func bashCommand(input json.RawMessage) string { var args struct { Command string `json:"command"` } json.Unmarshal(input, &args) return args.Command } // shellCmd is one simple command, as words. type shellCmd struct { words []string // fixed[i] says words[i] is exactly what the shell will pass: no // expansion, no unquoted glob. Only fixed words can match a rule's own // words; anything else can only fall under its trailing wildcard. fixed []bool } // bashAllows is the strict reading for one simple command. func (r Rule) bashAllows(c shellCmd) bool { want, prefix := r.bashPattern() if want == nil { // A rule with a wildcard in the middle is matched against the command // as one string, which only means anything when every word is fixed. for _, f := range c.fixed { if !f { return false } } return wildcardMatch(r.Specifier, strings.Join(c.words, " ")) } if len(c.words) < len(want) || (!prefix && len(c.words) != len(want)) { return false } for i, w := range want { if !c.fixed[i] || c.words[i] != w { return false } } return true } // bashWordsMatch is the lenient reading for one candidate: the words as // written, expansions and all, against the rule. func (r Rule) bashWordsMatch(words []string) bool { want, prefix := r.bashPattern() if want == nil { return wildcardMatch(r.Specifier, strings.Join(words, " ")) } if len(words) < len(want) || (!prefix && len(words) != len(want)) { return false } for i, w := range want { if words[i] != w { return false } } return true } // bashPattern reads a Bash rule's argument. `git status:*` and `git status *` // are a prefix, matched word by word, so Bash(ls:*) covers `ls -la` and not // `lsof`; anything without a wildcard is an exact command. A wildcard anywhere // else returns nil, and the argument is matched as a pattern over the whole // command line. func (r Rule) bashPattern() (words []string, prefix bool) { spec := r.Specifier switch { case strings.HasSuffix(spec, ":*"): spec, prefix = strings.TrimSuffix(spec, ":*"), true case strings.HasSuffix(spec, " *"): spec, prefix = strings.TrimSuffix(spec, " *"), true } if strings.Contains(spec, "*") { return nil, false } return strings.Fields(spec), prefix } // wildcardMatch matches s against a pattern in which * stands for any run of // characters, and nothing else is special. func wildcardMatch(pattern, s string) bool { parts := strings.Split(pattern, "*") if len(parts) == 1 { return pattern == s } if !strings.HasPrefix(s, parts[0]) { return false } s = s[len(parts[0]):] for _, p := range parts[1 : len(parts)-1] { i := strings.Index(s, p) if i < 0 { return false } s = s[i+len(p):] } return strings.HasSuffix(s, parts[len(parts)-1]) } // shellCommands lists the simple commands in a script, and reports whether // that list is the whole truth about what it runs. It is not when the script // does not parse, substitutes a command, runs an inline interpreter script, // assigns a variable — PATH=. changes what every later word means — or writes // output to a file, which no command prefix says anything about. func shellCommands(script string) ([]shellCmd, bool) { f, err := syntax.NewParser().Parse(strings.NewReader(script), "") if err != nil { return nil, false } exact := true var cmds []shellCmd syntax.Walk(f, func(node syntax.Node) bool { switch n := node.(type) { case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.DeclClause, *syntax.CoprocClause: exact = false case *syntax.Stmt: for _, rd := range n.Redirs { if writesFile(rd) { exact = false } } case *syntax.CallExpr: if len(n.Assigns) > 0 { exact = false } if len(n.Args) == 0 { return true } c := shellCmd{} for _, w := range n.Args { text, fixed := wordText(w) c.words = append(c.words, text) c.fixed = append(c.fixed, fixed) } if !c.fixed[0] { // A command name the shell computes is a command nobody // can name in a rule. exact = false } if interpreters[c.words[0]] && inlineScript(c.words[1:]) { exact = false } cmds = append(cmds, c) } return true }) return cmds, exact } // writesFile reports whether a redirection sends output somewhere other than // another descriptor or /dev/null. func writesFile(rd *syntax.Redirect) bool { target, fixed := wordText(rd.Word) switch rd.Op { case syntax.RdrOut, syntax.AppOut, syntax.RdrAll, syntax.AppAll, syntax.ClbOut, syntax.RdrInOut: case syntax.DplOut: // >&2 duplicates a descriptor; >&name, in bash, writes a file. if fixed && (target == "-" || strings.Trim(target, "0123456789") == "") { return false } default: return false } return !fixed || target != "/dev/null" } // inlineScript is hasInlineScript for words already read out. func inlineScript(args []string) bool { for _, a := range args { if a == "-c" || a == "-e" || a == "--command" { return true } } return false } // wordText renders a word the way the shell will see it after quote removal, // and reports whether that rendering is certain. A word with an expansion in // it is rendered as written, which is good enough for a deny rule to look at // and never good enough for an allow rule to trust. func wordText(w *syntax.Word) (string, bool) { var b strings.Builder fixed := true for i, part := range w.Parts { switch p := part.(type) { case *syntax.Lit: if strings.ContainsAny(p.Value, "*?[") || (i == 0 && strings.HasPrefix(p.Value, "~")) || (strings.Contains(p.Value, "{") && strings.Contains(p.Value, "}") && (strings.Contains(p.Value, ",") || strings.Contains(p.Value, ".."))) { // A glob, a home directory or a brace expansion: the shell // passes something other than what is written. fixed = false } b.WriteString(unescape(p.Value, false)) case *syntax.SglQuoted: b.WriteString(p.Value) case *syntax.DblQuoted: for _, inner := range p.Parts { if lit, ok := inner.(*syntax.Lit); ok { b.WriteString(unescape(lit.Value, true)) continue } fixed = false b.WriteString(source(inner)) } default: fixed = false b.WriteString(source(part)) } } return b.String(), fixed } // unescape removes the backslashes the shell would. Outside quotes every // backslash escapes the next character; inside double quotes only a few // characters can be escaped and the backslash stays before anything else. func unescape(s string, quoted bool) string { if !strings.Contains(s, `\`) { return s } var b strings.Builder for i := 0; i < len(s); i++ { if s[i] != '\\' || i+1 == len(s) { b.WriteByte(s[i]) continue } next := s[i+1] switch { case next == '\n': i++ case !quoted || strings.IndexByte("$`\"\\", next) >= 0: b.WriteByte(next) i++ default: b.WriteByte(s[i]) } } return b.String() } // source prints a node back as shell text. func source(n syntax.Node) string { var b strings.Builder syntax.NewPrinter().Print(&b, n) return b.String() } // maxShellDepth bounds how far denyCandidates follows `bash -c` into `bash -c`. const maxShellDepth = 4 // denyCandidates is every reading of a script a deny rule should be tried // against: each simple command, each again with a wrapper like sudo or env // taken off the front and its path stripped to the program name, and the // script inside a `bash -c` or an eval. A script that does not parse is split // on the obvious separators instead, because being unparseable is no reason to // escape a rule that would have caught it. func denyCandidates(script string, depth int) [][]string { f, err := syntax.NewParser().Parse(strings.NewReader(script), "") if err != nil { var out [][]string for _, piece := range strings.FieldsFunc(script, func(r rune) bool { return strings.ContainsRune(";&|\n()`", r) }) { if words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(piece, `"`, ""), "'", "")); len(words) > 0 { out = append(out, unwrapped(words)...) } } return out } var out [][]string syntax.Walk(f, func(node syntax.Node) bool { call, ok := node.(*syntax.CallExpr) if !ok || len(call.Args) == 0 { return true } words := make([]string, 0, len(call.Args)) for _, w := range call.Args { text, _ := wordText(w) words = append(words, text) } for _, cand := range unwrapped(words) { out = append(out, cand) if depth >= maxShellDepth { continue } if inner, ok := innerScript(cand); ok { out = append(out, denyCandidates(inner, depth+1)...) } } return true }) return out } // innerScript is the script a command runs as a string: the argument of // `sh -c`, or everything handed to eval. func innerScript(words []string) (string, bool) { switch words[0] { case "eval": return strings.Join(words[1:], " "), len(words) > 1 case "bash", "sh", "zsh", "dash", "ksh": for i, w := range words[1:] { if w == "-c" && i+2 < len(words) { return words[i+2], true } } } return "", false } // wrappers run the command that follows them, so that `sudo rm -rf x` and // `timeout 5 rm -rf x` are both read as rm. var wrappers = map[string]bool{ "sudo": true, "doas": true, "command": true, "builtin": true, "exec": true, "nohup": true, "nice": true, "time": true, "env": true, "timeout": true, "xargs": true, "stdbuf": true, "ionice": true, } // unwrapped is a command, then the same command each time a wrapper comes off // the front, each with a path on the program name reduced to the name itself: // /bin/rm is rm as far as a rule is concerned. func unwrapped(words []string) [][]string { var out [][]string for len(words) > 0 { if strings.Contains(words[0], "/") { words = append([]string{path.Base(words[0])}, words[1:]...) } out = append(out, words) if !wrappers[words[0]] { break } wrapper := words[0] words = words[1:] // Skip the wrapper's own options and assignments. The value of an // option given as a separate word, and timeout's duration, are taken // off too; a guess that takes off one word too many only means a rule // sees a shorter command, never that it sees nothing. for len(words) > 0 { w := words[0] switch { case strings.HasPrefix(w, "-"): words = words[1:] if (wrapper == "sudo" || wrapper == "doas") && (w == "-u" || w == "-g") && len(words) > 0 { words = words[1:] } if wrapper == "nice" && w == "-n" && len(words) > 0 { words = words[1:] } continue case wrapper == "env" && strings.Contains(w, "="): words = words[1:] continue case wrapper == "timeout" && len(w) > 0 && unicode.IsDigit(rune(w[0])): words = words[1:] } break } } return out } // pathMatches reads a path pattern the way Claude Code does, which is the way // gitignore does: // starts an absolute path, ~/ the home directory, / and ./ // the root, and a pattern with no slash in it matches a name at any depth. ** // crosses directories and * does not. A pattern ending in a slash is a // directory and everything under it. func (r Rule) pathMatches(p string) bool { pattern := r.Specifier // gitignore anchors a pattern with a slash anywhere but at its end; one // without is a name, matched at any depth. anchored := strings.Contains(strings.TrimSuffix(pattern, "/"), "/") if strings.HasSuffix(pattern, "/") { pattern += "**" } target := filepath.ToSlash(filepath.Clean(p)) if !filepath.IsAbs(p) && r.Root != "" { target = filepath.ToSlash(filepath.Join(r.Root, p)) } under := func(rel string) string { if r.Root == "" { return rel } return filepath.ToSlash(filepath.Clean(r.Root)) + "/" + rel } var full string switch { case strings.HasPrefix(pattern, "//"): full = pattern[1:] case strings.HasPrefix(pattern, "~/"): home := r.Home if home == "" { home, _ = os.UserHomeDir() } full = filepath.ToSlash(filepath.Clean(home)) + "/" + pattern[2:] case strings.HasPrefix(pattern, "./"): full = under(pattern[2:]) case strings.HasPrefix(pattern, "/"): full = under(pattern[1:]) case !anchored: full = under("**/" + pattern) default: full = under(pattern) } return globMatch(segments(full), segments(target)) } // segments splits a slash path into its parts, dropping the empty and "." // ones a join or a leading slash leaves behind. func segments(p string) []string { var out []string for _, s := range strings.Split(p, "/") { if s != "" && s != "." { out = append(out, s) } } return out } // globMatch matches path segments against pattern segments, with ** standing // for any number of segments, none included. func globMatch(pat, segs []string) bool { for len(pat) > 0 { if pat[0] == "**" { for i := 0; i <= len(segs); i++ { if globMatch(pat[1:], segs[i:]) { return true } } return false } if len(segs) == 0 { return false } if ok, err := path.Match(pat[0], segs[0]); err != nil || !ok { return false } pat, segs = pat[1:], segs[1:] } return len(segs) == 0 }