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 }