package ccsettings // TEMPORARY MATCHER — swap at merge. // // This file is a deliberately small parser and matcher for Claude Code // permission rules, written so the loading and honouring in this package could // be tested end to end while the shared rule parser (ParseRule / Rule.Matches) // was being written elsewhere. Everything outside this file talks to it // through compile and the compiled interface, so replacing it means writing an // adapter from the shared Rule to compiled and deleting this file. The // tri-state result and the allow-versus-deny distinction for compound shell // commands are the parts an adapter must keep; see Match and matchMode. import ( "encoding/json" "fmt" "net/url" "path/filepath" "regexp" "strings" "github.com/bmatcuk/doublestar/v4" "github.com/johannSo/codey/internal/approval" ) // Match is the result of checking one rule against one call. type Match int const ( NoMatch Match = iota Matched // Unsure means the call cannot be pinned down well enough to say — an // opaque shell command, a word built at run time. An allow rule treats it // as no match; a deny or ask rule treats it as a reason to ask. Unsure ) // matchMode is which kind of rule is asking. It matters for compound shell // commands: an allow rule covers "a && b" only if it covers both, while a // deny rule catches it if it catches either. type matchMode int const ( forAllow matchMode = iota forRestrict ) // compiled is one parsed rule. type compiled interface { Match(tool string, input json.RawMessage, mode matchMode) Match } // matchEnv is what relative rule paths are resolved against. type matchEnv struct { home, root string } // codeyTools maps Claude Code's tool names to Codey's. Edit covers both of // Codey's writing tools, as Claude Code's Edit rules cover all of its own; Read // covers the search tools as well, on a best-effort basis, as it does there. var codeyTools = map[string][]string{ "Bash": {"bash"}, "Read": {"read_file", "glob", "grep"}, "Edit": {"edit_file", "write_file"}, "MultiEdit": {"edit_file"}, "Write": {"write_file"}, "Glob": {"glob"}, "Grep": {"grep"}, "WebFetch": {"web_fetch"}, "WebSearch": {"web_search"}, "TodoWrite": {"todo_write"}, } var ruleShape = regexp.MustCompile(`^([A-Za-z0-9_\-]+(?:__\*)?)(?:\((.*)\))?$`) // compile parses a rule. An error means the rule names something Codey has no // equivalent for, or uses a specifier this matcher does not understand; the // caller reports it and the rule takes no part. func compile(rule string, env matchEnv) (compiled, error) { m := ruleShape.FindStringSubmatch(strings.TrimSpace(rule)) if m == nil { return nil, fmt.Errorf("not a rule Codey can read") } name, spec := m[1], strings.TrimSpace(m[2]) hasSpec := spec != "" && spec != "*" if strings.HasPrefix(name, "mcp__") { if m[2] != "" { return nil, fmt.Errorf("an MCP rule takes no specifier") } return mcpRule(name), nil } tools, ok := codeyTools[name] if !ok { return nil, fmt.Errorf("Codey has no %s tool", name) } if !hasSpec { return toolRule(tools), nil } switch name { case "Bash": return newBashRule(spec), nil case "Read", "Edit", "MultiEdit", "Write", "Glob", "Grep": return newPathRule(tools, spec, env), nil case "WebFetch": if d, ok := strings.CutPrefix(spec, "domain:"); ok && d != "" { return domainRule(strings.ToLower(d)), nil } } return nil, fmt.Errorf("Codey does not understand the specifier %q", spec) } // toolRule is a bare tool name: every call of it. type toolRule []string func (r toolRule) Match(tool string, _ json.RawMessage, _ matchMode) Match { for _, t := range r { if t == tool { return Matched } } return NoMatch } // mcpRule is mcp__server, mcp__server__* or mcp__server__tool. type mcpRule string func (r mcpRule) Match(tool string, _ json.RawMessage, _ matchMode) Match { name := string(r) if server, ok := strings.CutSuffix(name, "__*"); ok { name = server } if strings.Count(name, "__") == 1 { // The whole server. if strings.HasPrefix(tool, name+"__") { return Matched } return NoMatch } if tool == name { return Matched } return NoMatch } // bashRule is Bash(spec): an exact command, a prefix written "cmd:*" or // "cmd *", or a pattern with * in it. type bashRule struct { prefix []string // set for a prefix rule exact []string // set for an exact rule pattern *regexp.Regexp } func newBashRule(spec string) bashRule { if p, ok := strings.CutSuffix(spec, ":*"); ok { return bashRule{prefix: strings.Fields(p)} } if p, ok := strings.CutSuffix(spec, " *"); ok && !strings.Contains(p, "*") { return bashRule{prefix: strings.Fields(p)} } if strings.Contains(spec, "*") { parts := strings.Split(strings.Join(strings.Fields(spec), " "), "*") for i, p := range parts { parts[i] = regexp.QuoteMeta(p) } return bashRule{pattern: regexp.MustCompile("^" + strings.Join(parts, ".*") + "$")} } return bashRule{exact: strings.Fields(spec)} } func (r bashRule) Match(tool string, input json.RawMessage, mode matchMode) Match { if tool != "bash" { return NoMatch } var args struct { Command string `json:"command"` } json.Unmarshal(input, &args) cmds, info := approval.SimpleCommands(args.Command) if mode == forAllow { // Allowing is all or nothing: every command in the line must be // covered, and a line whose commands cannot all be seen covers // nothing. if info.Opaque || info.ParseError != nil || len(cmds) == 0 { return NoMatch } for _, c := range cmds { if !c.Pinned || r.one(c) != Matched { return NoMatch } } return Matched } // Restricting needs one command to be caught. A line that cannot be seen // all the way through might hide one, which is Unsure rather than a pass. unsure := info.Opaque || info.ParseError != nil for _, c := range cmds { switch r.one(c) { case Matched: return Matched case Unsure: unsure = true } } if unsure { return Unsure } return NoMatch } // one matches a single simple command. func (r bashRule) one(c approval.SimpleCommand) Match { switch { case r.prefix != nil: return wordsMatch(r.prefix, c, true) case r.exact != nil: return wordsMatch(r.exact, c, false) default: for _, lit := range c.Literal { if !lit { return Unsure } } if r.pattern.MatchString(strings.Join(c.Words, " ")) { return Matched } return NoMatch } } // wordsMatch compares a rule's words to a command's. A word the shell builds // at run time could be anything, so meeting one before the comparison is // settled is Unsure. func wordsMatch(want []string, c approval.SimpleCommand, prefix bool) Match { if len(c.Words) < len(want) || (!prefix && len(c.Words) != len(want)) { return NoMatch } for i, w := range want { if !c.Literal[i] { return Unsure } if c.Words[i] != w { return NoMatch } } return Matched } // pathRule is Read(spec), Edit(spec) and the like: a gitignore-style pattern // over the path a tool is handed. type pathRule struct { tools []string pattern string // absolute, slash-separated, doublestar syntax root string } // newPathRule resolves a pattern the way Claude Code documents it: // //path is absolute, ~/path is under the home directory, /path is under // the project root, and anything else is relative to the working directory — // which for Codey is the root. A pattern with no slash in it matches at any // depth, as it would in .gitignore. func newPathRule(tools []string, spec string, env matchEnv) pathRule { spec = strings.TrimSuffix(spec, "/") var p string switch { case strings.HasPrefix(spec, "//"): p = spec[1:] case strings.HasPrefix(spec, "~/"): p = escape(filepath.ToSlash(env.home)) + spec[1:] case strings.HasPrefix(spec, "/"): p = escape(filepath.ToSlash(env.root)) + spec default: rel := strings.TrimPrefix(spec, "./") if !strings.Contains(rel, "/") && !strings.HasPrefix(spec, "./") { rel = "**/" + rel } p = escape(filepath.ToSlash(env.root)) + "/" + rel } return pathRule{tools: tools, pattern: p, root: env.root} } func (r pathRule) Match(tool string, input json.RawMessage, mode matchMode) Match { if toolRule(r.tools).Match(tool, nil, mode) != Matched { return NoMatch } var args struct { Path string `json:"path"` } json.Unmarshal(input, &args) path := args.Path if path == "" { path = r.root } if !filepath.IsAbs(path) { path = filepath.Join(r.root, path) } candidates := []string{filepath.Clean(path)} // The tools follow symlinks, so a rule about .env has to catch a link // to it under another name. if real, err := filepath.EvalSymlinks(path); err == nil && real != candidates[0] { candidates = append(candidates, real) } hits := 0 for _, c := range candidates { if r.covers(filepath.ToSlash(c)) { hits++ } } switch { case mode == forAllow && hits == len(candidates): return Matched case mode == forRestrict && hits > 0: return Matched } return NoMatch } // covers reports whether the pattern matches the path or any directory above // it: a rule about a directory, as in .gitignore, is a rule about everything // inside it. func (r pathRule) covers(path string) bool { for p := path; ; { if ok, _ := doublestar.Match(r.pattern, p); ok { return true } parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(p))) if parent == p { return false } p = parent } } func escape(s string) string { var b strings.Builder for _, r := range s { if strings.ContainsRune(`*?[]{}\`, r) { b.WriteByte('\\') } b.WriteRune(r) } return b.String() } // domainRule is WebFetch(domain:example.com). *.example.com covers the // subdomains; the bare name covers only itself. type domainRule string func (r domainRule) Match(tool string, input json.RawMessage, _ matchMode) Match { if tool != "web_fetch" { return NoMatch } var args struct { URL string `json:"url"` } json.Unmarshal(input, &args) u, err := url.Parse(args.URL) if err != nil || u.Hostname() == "" { return Unsure } host := strings.ToLower(u.Hostname()) d := string(r) if sub, ok := strings.CutPrefix(d, "*."); ok { if strings.HasSuffix(host, "."+sub) { return Matched } return NoMatch } if host == d { return Matched } return NoMatch }