123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 | package policy
import (
"errors"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"slices"
"strings"
)
// A named policy is one in a file of its own, policies/<name>.policy, for
// blinker to run a program under, or for other policies to include: the
// agent's (agent-safe and agent-unsafe), which a project's policies
// include, and the building blocks they are made of.
// CommandDir is the directory, beside the policies, of those named after
// the command they are for (commands/claude.policy), which blinker uses for
// that command unless told otherwise.
const CommandDir = "commands"
// ErrNoPolicy is LoadNamed finding no policy by the name.
var ErrNoPolicy = errors.New("no policy")
// NamedDirs are where policies are looked for, in order: the user's, in
// the config directory, then those installed in the data directories.
func NamedDirs(configDir string, dataDirs []string) []string {
dirs := []string{filepath.Join(configDir, "policies")}
for _, d := range dataDirs {
dirs = append(dirs, filepath.Join(d, "policies"))
}
return dirs
}
// OutsideProject drops the directories in the project, or leading into it:
// a project's own policies are not to be picked up through a variable it
// set, such as XDG_DATA_DIRS from its .envrc.
func OutsideProject(dirs []string, project string) []string {
return slices.DeleteFunc(dirs, func(d string) bool {
real, err := filepath.EvalSymlinks(d)
if err != nil {
real = d // missing: judged as written
}
return Under(real, project) || Under(d, project)
})
}
// NamedPolicies lists the names of the policies in dirs and builtin.
func NamedPolicies(dirs []string, builtin fs.FS) []string {
var names []string
list := func(entries []fs.DirEntry) {
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".policy")
if ok && ValidName(name) == nil && !slices.Contains(names, name) {
names = append(names, name)
}
}
}
for _, dir := range dirs {
if entries, err := os.ReadDir(dir); err == nil {
list(entries)
} // else a dir that is missing or unreadable has none to list
}
if entries, err := fs.ReadDir(builtin, "."); err == nil {
list(entries)
}
slices.Sort(names)
return names
}
// LoadNamed reads a policy from the first of dirs that has it, or else
// from builtin, the policies blinker is built with, with what it includes.
// The name may join several with "+" (write+web): their rules then follow
// one another, as if a policy included each in turn, and each makes its
// missing directories, as the policy being run.
func LoadNamed(dirs []string, builtin fs.FS, name string) (*File, error) {
l := &namedLoader{dirs: dirs, builtin: builtin, seen: map[string]bool{}}
out := &File{}
for _, n := range strings.Split(name, "+") {
f, _, err := l.load(n)
if err != nil {
return nil, err
}
out.Files = append(out.Files, f.Files...)
out.Network = append(out.Network, f.Network...)
out.Env = append(out.Env, f.Env...)
out.Tools = append(out.Tools, f.Tools...)
}
return out, nil
}
// LoadCommandPolicy reads the policy for a command, from CommandDir in
// the first of dirs that has it, or else in builtin, with what it
// includes.
func LoadCommandPolicy(dirs []string, builtin fs.FS, command string) (*File, error) {
return LoadNamed(dirs, builtin, CommandDir+"/"+command)
}
// CommandPolicyDirs are where the commands' policies are: CommandDir in
// each of dirs, and in builtin.
func CommandPolicyDirs(dirs []string, builtin fs.FS) ([]string, fs.FS, error) {
out := make([]string, len(dirs))
for i, d := range dirs {
out[i] = filepath.Join(d, CommandDir)
}
sub, err := fs.Sub(builtin, CommandDir)
return out, sub, err
}
// namedLoader reads policies and those they include. A policy's "include
// NAME" line stands for NAME's rules, where the line is, so the rules after
// it win where both match. NAME, here and in LoadNamed, is a policy, or,
// if there is none by that name, a command's; commands/NAME is a command's
// outright. Each policy is read once, where it is first named: naming it
// again adds nothing. An include that leads back to a policy it is in is
// an error. An included rule makes no missing directory (see
// Matcher.Dir): only the policies being run make their own.
type namedLoader struct {
dirs []string
builtin fs.FS
// override has policies, by name, to use in place of what dirs and
// builtin have: changes the user is reviewing.
override map[string]*File
seen map[string]bool // policies read so far, as commands/NAME for a command's
stack []string // the policies being read, outermost first
}
// find reads the policy a name means, and says which (commands/NAME for a
// command's) and where it was.
func (l *namedLoader) find(name string) (key string, data []byte, where string, err error) {
if f := l.override[name]; f != nil {
return name, f.Marshal(), "the proposed " + name, nil
}
command, isCommand := strings.CutPrefix(name, CommandDir+"/")
if !isCommand {
data, where, err = readNamed(l.dirs, l.builtin, name)
if !errors.Is(err, ErrNoPolicy) {
return name, data, where, err
}
command = name
}
commandDirs, commandFS, cerr := CommandPolicyDirs(l.dirs, l.builtin)
if cerr != nil {
return "", nil, "", cerr
}
data, where, cerr = readNamed(commandDirs, commandFS, command)
if errors.Is(cerr, ErrNoPolicy) && !isCommand {
return "", nil, "", fmt.Errorf("%w, nor one for a command named %q", err, command)
}
return CommandDir + "/" + command, data, where, cerr
}
// load reads a named policy with what it includes, or nothing if it was
// read already, and says which it was (commands/NAME for a command's).
func (l *namedLoader) load(name string) (*File, string, error) {
key, data, where, err := l.find(name)
if err != nil {
return nil, "", err
}
if slices.Contains(l.stack, key) {
return nil, "", fmt.Errorf("include goes round in a circle: %s", strings.Join(append(l.stack, key), " -> "))
}
if l.seen[key] {
return &File{}, key, nil
}
l.seen[key] = true
l.stack = append(l.stack, key)
defer func() { l.stack = l.stack[:len(l.stack)-1] }()
f, err := Parse(data)
if err != nil {
return nil, "", fmt.Errorf("%s: %w", where, err)
}
if f.Directory != "" {
return nil, "", fmt.Errorf("%s: a named policy has no project line", where)
}
f, err = l.expand(f, where)
return f, key, err
}
// expand is f with each include line replaced by the rules it stands for.
// where names f in errors.
func (l *namedLoader) expand(f *File, where string) (*File, error) {
out := &File{Directory: f.Directory}
var nf, nn, ne, nt int // f's rules put in out so far
own := func(upTo int) {
for ; nf < len(f.Files) && f.Files[nf].Line < upTo; nf++ {
out.Files = append(out.Files, f.Files[nf])
}
for ; nn < len(f.Network) && f.Network[nn].Line < upTo; nn++ {
out.Network = append(out.Network, f.Network[nn])
}
for ; ne < len(f.Env) && f.Env[ne].Line < upTo; ne++ {
out.Env = append(out.Env, f.Env[ne])
}
for ; nt < len(f.Tools) && f.Tools[nt].Line < upTo; nt++ {
out.Tools = append(out.Tools, f.Tools[nt])
}
}
for _, inc := range f.Includes {
own(inc.Line)
base, key, err := l.load(inc.Name)
if err != nil {
return nil, fmt.Errorf("%s: include %s: %w", where, inc.Name, err)
}
included(base, "policy "+key)
out.Files = append(out.Files, base.Files...)
out.Network = append(out.Network, base.Network...)
out.Env = append(out.Env, base.Env...)
out.Tools = append(out.Tools, base.Tools...)
}
own(math.MaxInt)
return out, nil
}
// included makes f's rules another policy's: not lines of the file that
// includes them, but from the named policy (unless from one it included
// in turn), and making no directory (see Matcher.Dir).
func included(f *File, from string) {
at := func(line int, was string) string {
if was != "" {
return was
}
return fmt.Sprintf("%s line %d", from, line)
}
for i := range f.Files {
r := &f.Files[i]
if r.Path != "/" {
r.Path = strings.TrimSuffix(r.Path, "/")
}
r.From, r.Line = at(r.Line, r.From), 0
}
for i := range f.Network {
r := &f.Network[i]
r.From, r.Line = at(r.Line, r.From), 0
}
for i := range f.Env {
r := &f.Env[i]
r.From, r.Line = at(r.Line, r.From), 0
}
for i := range f.Tools {
r := &f.Tools[i]
r.From, r.Line = at(r.Line, r.From), 0
}
}
// readNamed reads the policy name from the first of dirs that has it, or
// else from builtin, and says where it was.
func readNamed(dirs []string, builtin fs.FS, name string) (data []byte, where string, err error) {
if err := ValidName(name); err != nil {
return nil, "", err
}
for _, dir := range dirs {
path := filepath.Join(dir, name+".policy")
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, "", err
}
return data, path, nil
}
data, err = fs.ReadFile(builtin, name+".policy")
if err == nil {
return data, "the built-in policy " + name, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return nil, "", err
}
have := "there are none"
if names := NamedPolicies(dirs, builtin); len(names) > 0 {
have = "there are " + strings.Join(names, ", ")
}
return nil, "", fmt.Errorf("%w %q (%s): write one in %s", ErrNoPolicy, name, have, filepath.Join(dirs[0], name+".policy"))
}
// ValidName checks a policy's name, which becomes a file name:
// letters, digits, ".", "-" and "_", not starting with ".".
func ValidName(name string) error {
if name == "" || len(name) > 64 || name[0] == '.' {
return fmt.Errorf("bad policy name %q", name)
}
for _, c := range name {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-' || c == '_') {
return fmt.Errorf("bad policy name %q: use letters, digits, '.', '-' and '_'", name)
}
}
return nil
}
|