Vectrum
Sign In
156 lines · 5.8 KB · Go
package ccsettings

import (
	"encoding/json"
	"fmt"

	"github.com/johannSo/codey/internal/approval"
)

// Ruleset is Claude Code's permission rules as Codey honours them. It
// implements approval.RuleSet, so the approval policy consults it before
// anything else — before -yolo, before the read-only list, and before the
// session's own memory.
type Ruleset struct {
	deny, ask, allow []entry
	// acceptEdits names the file whose defaultMode said acceptEdits, empty
	// when none did.
	acceptEdits string
	lines       []string
}

type entry struct {
	rule, source string
	c            compiled
}

// NewRuleset decides which of the rules in perms are honoured and compiles
// them. The rules, by kind and by who wrote them:
//
//   - deny and ask, from every file. They can only make the agent ask more or
//     do less, and nobody is put at risk by that, so a repository may add as
//     many as it likes.
//   - allow, from the user's settings and the project's local file. Both are
//     the user's own writing.
//   - allow from the project's shared file — or from a settings.local.json
//     git is tracking, which is the same thing under another name — only when
//     projectTrusted. Otherwise a repository could decide for itself what
//     runs on your machine without asking, which is precisely the decision
//     the prompt exists to leave with you.
//   - defaultMode, only acceptEdits, and only from the user's own files. The
//     other modes either mean nothing here or mean "stop asking", and the
//     only way to stop asking in Codey is to say so on the command line.
//
// The notes are rules that were not understood and modes that were ignored,
// for the caller to report; an untrusted project's allow rules are left for
// the caller to report too, since only it knows how to ask for trust.
func NewRuleset(home, root string, perms []Permissions, projectTrusted bool) (*Ruleset, []string) {
	env := matchEnv{home: home, root: root}
	r := &Ruleset{}
	var notes []string
	for _, p := range perms {
		label := p.Shown
		switch {
		case p.Tracked:
			label += " (committed to the repository, so treated as shared)"
		case p.Scope == ScopeProject:
			label += " (shared, from the repository)"
		}
		var lines []string
		add := func(kind string, rules []string, into *[]entry, honoured bool) {
			for _, rule := range rules {
				c, err := compile(rule, env)
				switch {
				case err != nil:
					notes = append(notes, fmt.Sprintf("claude code rule %s %s in %s ignored: %v", kind, rule, p.Shown, err))
					lines = append(lines, fmt.Sprintf("  %-6s %s — ignored: %v", kind, rule, err))
				case !honoured:
					lines = append(lines, fmt.Sprintf("  %-6s %s — ignored until this project is trusted", kind, rule))
				default:
					*into = append(*into, entry{rule: rule, source: p.Shown, c: c})
					lines = append(lines, fmt.Sprintf("  %-6s %s", kind, rule))
				}
			}
		}
		add("deny", p.Deny, &r.deny, true)
		add("ask", p.Ask, &r.ask, true)
		add("allow", p.Allow, &r.allow, p.Scope != ScopeProject || projectTrusted)

		switch mode := p.DefaultMode; {
		case mode == "" || mode == "default":
		case mode == "acceptEdits" && p.Scope != ScopeProject:
			if r.acceptEdits == "" {
				r.acceptEdits = p.Shown
			}
			lines = append(lines, "  mode   acceptEdits — file edits run without asking")
		case p.Scope == ScopeProject:
			notes = append(notes, fmt.Sprintf("claude code defaultMode %q in %s ignored: a repository does not get to choose how often you are asked", mode, p.Shown))
			lines = append(lines, fmt.Sprintf("  mode   %s — ignored: set by the repository", mode))
		default:
			notes = append(notes, fmt.Sprintf("claude code defaultMode %q in %s ignored: Codey honours only acceptEdits and default; -yolo is the way to stop asking", mode, p.Shown))
			lines = append(lines, fmt.Sprintf("  mode   %s — ignored", mode))
		}
		if len(lines) > 0 {
			r.lines = append(r.lines, label)
			r.lines = append(r.lines, lines...)
		}
	}
	return r, notes
}

// Empty reports whether there is nothing here to consult or to show, so a
// caller can leave the policy's rule set nil.
func (r *Ruleset) Empty() bool { return r == nil || len(r.lines) == 0 }

// Check implements approval.RuleSet. Deny outranks ask outranks allow, as in
// Claude Code, so no allow rule anywhere can argue a call past a deny rule.
func (r *Ruleset) Check(tool string, input json.RawMessage) approval.Ruling {
	if r == nil {
		return approval.Ruling{}
	}
	// A deny rule that cannot tell whether it applies — a shell command it
	// cannot see all the way through — does not get to refuse, since the
	// command may be innocent, but it does not get to wave it through either.
	// It becomes a question.
	var unsure *entry
	for i, e := range r.deny {
		switch e.c.Match(tool, input, forRestrict) {
		case Matched:
			return approval.Ruling{Verdict: approval.VerdictDeny, Rule: e.rule, Source: e.source}
		case Unsure:
			if unsure == nil {
				unsure = &r.deny[i]
			}
		}
	}
	for _, e := range r.ask {
		if e.c.Match(tool, input, forRestrict) != NoMatch {
			return approval.Ruling{Verdict: approval.VerdictAsk, Rule: e.rule, Source: e.source}
		}
	}
	if unsure != nil {
		return approval.Ruling{
			Verdict: approval.VerdictAsk,
			Rule:    unsure.rule,
			Source:  unsure.source + ", a deny rule this command cannot be checked against",
		}
	}
	for _, e := range r.allow {
		if e.c.Match(tool, input, forAllow) == Matched {
			return approval.Ruling{Verdict: approval.VerdictAllow, Rule: e.rule, Source: e.source}
		}
	}
	if r.acceptEdits != "" && (tool == "edit_file" || tool == "write_file") {
		return approval.Ruling{Verdict: approval.VerdictAllow, Rule: "defaultMode acceptEdits", Source: r.acceptEdits}
	}
	return approval.Ruling{}
}

// Lines implements approval.RuleSet: every file, then its rules, each marked
// with whether it is in force.
func (r *Ruleset) Lines() []string {
	if r == nil {
		return nil
	}
	return r.lines
}