Vectrum
Sign In
865 lines · 26.1 KB · Go
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
}