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