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
}