Vectrum
Sign In
128 lines · 3.8 KB · Go
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()
}