package approval
import (
"encoding/json"
"reflect"
)
// Verdict is what a set of standing rules says about one tool call, before
// anyone is asked and before anything the session remembers is consulted.
type Verdict int
const (
// NoVerdict means no rule spoke, and the call is decided the ordinary way:
// the mode, the session's memory, and then a prompt.
NoVerdict Verdict = iota
// VerdictAllow lets the call through without a prompt.
VerdictAllow
// VerdictAsk puts the call to a human even when the mode would have let
// it through, and even when the session remembers allowing it.
VerdictAsk
// VerdictDeny refuses the call outright, in every mode, -yolo included.
VerdictDeny
)
// Ruling is a verdict and the rule that produced it, for the message the model
// reads and the prompt a human reads. "Denied" with no reason is a dead end;
// "denied by Bash(rm:*) in ~/.claude/settings.json" is something to fix.
type Ruling struct {
Verdict Verdict
// Rule is the rule as it was written, e.g. Bash(npm run test:*).
Rule string
// Source is where the rule was written, e.g. ~/.claude/settings.json.
Source string
}
// RuleSet is a set of standing rules that act before the session's own
// memory: rules written down in a settings file rather than given as an
// answer to a prompt. Claude Code's permissions.allow, deny and ask are the
// ones that exist so far.
//
// It is an interface so this package stays ignorant of where rules come from
// and of the grammar they are written in. Whoever loads them decides which
// files are honoured; the policy only acts on what they say.
type RuleSet interface {
// Check rules on one call. Deny outranks ask, which outranks allow, so a
// single rule set can never be argued out of a refusal by a broader
// permission elsewhere in it.
Check(tool string, input json.RawMessage) Ruling
// Lines describe the rules for /allowed, grouped by where they came from.
Lines() []string
}
// check is Rules.Check, tolerating a policy with no rules.
func (p *Policy) check(tool string, input json.RawMessage) Ruling {
if p.Rules == nil {
return Ruling{}
}
return p.Rules.Check(tool, input)
}
// RuleLines is what /allowed shows under the session's own memory: the
// standing rules, and the files they came from. Nil when there are none.
func (p *Policy) RuleLines() []string {
if p == nil || p.Rules == nil {
return nil
}
return p.Rules.Lines()
}
// because names the rule behind a ruling, for a message.
func (r Ruling) because() string {
if r.Source == "" {
return r.Rule
}
return r.Rule + " in " + r.Source
}
// Combine is several rule sets decided as one: the settings files' and the
// command line's. Each set has already applied its own precedence, so the
// combined ruling is the strongest any of them gave — a deny anywhere holds
// whatever another set allows, and an ask anywhere is asked. Nil sets are
// skipped, and nothing at all left is nil, so a policy with no rules stays
// one with no rules.
func Combine(sets ...RuleSet) RuleSet {
var kept combined
for _, s := range sets {
if s != nil && !isNilRuleSet(s) {
kept = append(kept, s)
}
}
switch len(kept) {
case 0:
return nil
case 1:
return kept[0]
}
return kept
}
type combined []RuleSet
func (c combined) Check(tool string, input json.RawMessage) Ruling {
var best Ruling
for _, s := range c {
if r := s.Check(tool, input); r.Verdict > best.Verdict {
best = r
}
}
return best
}
func (c combined) Lines() []string {
var lines []string
for _, s := range c {
lines = append(lines, s.Lines()...)
}
return lines
}
// isNilRuleSet catches the typed nil an interface holding a nil pointer is,
// and an empty RuleList, neither of which has anything to say.
func isNilRuleSet(s RuleSet) bool {
if l, ok := s.(RuleList); ok {
return l.Empty()
}
v := reflect.ValueOf(s)
return v.Kind() == reflect.Pointer && v.IsNil()
}