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.RuleList{Allow: allowRules, Deny: denyRules}
// ruling := set.Check("bash", input) // VerdictAllow, VerdictDeny or NoVerdict
// policy.Rules = set // a RuleSet, so 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, true)
}
}
// 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, false)
}
// matchesArg is the non-shell half of both readings. A URL is one thing; a
// path is two when a symlink is involved — the name the call gave and the
// file it reaches — and strict, the allow reading, wants both to match,
// where the deny reading is satisfied by either.
func (r Rule) matchesArg(tool string, input json.RawMessage, strict bool) 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.pathMatchesResolved(p, strict)
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
}
// RuleList is a list of allow rules and a list of deny rules, decided
// together. It is the RuleSet for rules given on the command line, with
// --allowedTools and --disallowedTools.
type RuleList struct {
Allow []Rule
Deny []Rule
// Source is where the rules were given, for the message a refusal
// carries and for /allowed. Empty reads as the command line.
Source string
}
// Empty reports whether there is nothing to decide with.
func (s RuleList) 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 named then is the first that matched.
func (s RuleList) Check(tool string, input json.RawMessage) Ruling {
v, r := s.decide(tool, input)
if v == NoVerdict {
return Ruling{}
}
return Ruling{Verdict: v, Rule: r.Raw, Source: s.source()}
}
// Lines implements RuleSet, for /allowed.
func (s RuleList) Lines() []string {
if s.Empty() {
return nil
}
lines := []string{s.source()}
for _, r := range s.Deny {
lines = append(lines, " deny "+r.Raw)
}
for _, r := range s.Allow {
lines = append(lines, " allow "+r.Raw)
}
return lines
}
func (s RuleList) source() string {
if s.Source == "" {
return "the command line"
}
return s.Source
}
func (s RuleList) decide(tool string, input json.RawMessage) (Verdict, Rule) {
for _, r := range s.Deny {
if r.Touches(tool, input) {
return VerdictDeny, r
}
}
if tool != "bash" {
for _, r := range s.Allow {
if r.Matches(tool, input) {
return VerdictAllow, r
}
}
return NoVerdict, 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 VerdictAllow, r
}
}
return NoVerdict, 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 NoVerdict, Rule{}
}
}
return VerdictAllow, first
}
// Unsure reports whether a bash call might fall under this rule in a way the
// shell analysis cannot see, which is only worth asking when Touches has said
// no. A deny rule that did not match a command it could not read has not
// cleared it, only failed to see it, and a caller that cares can ask instead
// of letting it pass.
//
// That is the case when the script does not parse, hides commands in a
// substitution or an inline interpreter script, or has a simple command whose
// words agree with the rule's up to one the shell computes: `git "$sub"
// origin` may well be `git push origin`. `ls *.go` is not unsure about
// Bash(rm:*) — its name is fixed, and it is not rm.
func (r Rule) Unsure(tool string, input json.RawMessage) bool {
if !r.Covers(tool) || r.WholeTool() || r.spec != specBash {
return false
}
script := bashCommand(input)
f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
if err != nil {
return true
}
hidden := false
syntax.Walk(f, func(node syntax.Node) bool {
switch n := node.(type) {
case *syntax.CmdSubst, *syntax.ProcSubst:
hidden = true
case *syntax.CallExpr:
if len(n.Args) > 0 {
if name, ok := wordText(n.Args[0]); ok && interpreters[name] {
var rest []string
for _, w := range n.Args[1:] {
t, _ := wordText(w)
rest = append(rest, t)
}
hidden = hidden || inlineScript(rest)
}
}
}
return !hidden
})
if hidden {
return true
}
want, _ := r.bashPattern()
cmds, _ := shellCommands(script)
for _, c := range cmds {
if want == nil {
// A mid-pattern wildcard is matched over the whole line, so any
// computed word could be the one it needed.
for _, fixed := range c.fixed {
if !fixed {
return true
}
}
continue
}
for i, w := range want {
if i >= len(c.words) {
break
}
if !c.fixed[i] {
return true
}
if c.words[i] != w {
break
}
}
}
return false
}
// 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
}
// pathMatchesResolved is pathMatches for the path as given and for the file it
// resolves to. Without the second, a link named notes.txt pointing at .env
// reads .env past Read(.env), and Edit(*.md) edits whatever a README.md link
// points at. A file that does not exist yet — write_file's — is resolved
// through its directory.
func (r Rule) pathMatchesResolved(p string, strict bool) bool {
given := r.pathMatches(p)
abs := p
if !filepath.IsAbs(abs) && r.Root != "" {
abs = filepath.Join(r.Root, abs)
}
real, err := filepath.EvalSymlinks(abs)
if err != nil {
dir, derr := filepath.EvalSymlinks(filepath.Dir(abs))
if derr != nil {
return given
}
real = filepath.Join(dir, filepath.Base(abs))
}
if real == filepath.Clean(abs) {
return given
}
// The resolved path is compared against the resolved root, or a root
// that is itself reached through a link would match nothing.
rr := r
if r.Root != "" {
if root, err := filepath.EvalSymlinks(r.Root); err == nil {
rr.Root = root
}
}
viaLink := rr.pathMatches(real)
if strict {
return given && viaLink
}
return given || viaLink
}
// 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 that matches a directory
// matches everything under it, as a gitignore line does: Read(secrets) is
// about the files in secrets/, not only the directory entry.
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)
}
pat, segs := segments(full), segments(target)
for i := len(segs); i > 0; i-- {
if globMatch(pat, segs[:i]) {
return true
}
}
return false
}
// 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
}