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
}