Download
557 lines 16.9 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
package policy

import (
	"bytes"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"sync"
	"time"

	"blinker/internal/config"
)

// The agent's policies live in blinker's config directory, never in the
// project, so a cloned repository cannot grant itself access. Each project
// has two, in policies/projects/<name>/: safe.policy, for Safe_Bash and
// the file tools without asking, and unsafe.policy, for Unsafe_Bash and
// the file tools once the user approves. Each names its project's
// directory, and starts by including the agent's own named policy for its
// mode, agent-safe or agent-unsafe.

// AgentPolicy is the agent's named policy for a mode, which a project's
// policy for it includes when it is made.
func AgentPolicy(m Mode) string { return "agent-" + m.String() }

// ProjectDir is where a project's (real path's) policies are.
func ProjectDir(configDir, project string) string {
	return filepath.Join(configDir, "policies", "projects", config.ProjectName(project))
}

// Store reads and writes the agent's policies.
type Store struct {
	ConfigDir string
	// Dirs and Builtin are where named policies are (see NamedDirs).
	Dirs    []string
	Builtin fs.FS
}

// Source is one policy file blinker can edit, and what it says.
type Source struct {
	Name string // for people: "the project's safe policy", "policy agent-safe"
	Path string // where it is, or will be written
	File *File  // as written
	Mode Mode   // the mode it is for
	// Project is the directory its project line names: the project's, or
	// "" for the agent's own, which has none.
	Project string
	// Exists is false for a file not written yet: File is then what
	// applies in its place, a new project policy or a built-in one.
	Exists bool
}

// Loaded is the agent's policies for a project, as read from disk, each
// indexed by Mode.
type Loaded struct {
	// Own are the project's policies.
	Own [2]Source
	// Agent are agent-safe and agent-unsafe.
	Agent [2]Source
	// Stacks are the project's policies with what they include.
	Stacks [2]*File
}

// Sources are the policies blinker edits for a project, by the names the
// model and the user know them by: the project's "safe" and "unsafe", and
// the agent's "agent-safe" and "agent-unsafe".
func (l *Loaded) Sources() map[string]Source {
	return map[string]Source{
		"safe":         l.Own[SafeMode],
		"unsafe":       l.Own[UnsafeMode],
		"agent-safe":   l.Agent[SafeMode],
		"agent-unsafe": l.Agent[UnsafeMode],
	}
}

// Check checks f as a new version of the policy: a project's names its
// directory, and the agent's names none.
func (src Source) Check(f *File) error {
	switch {
	case f.Directory == src.Project:
		return nil
	case src.Project == "":
		return errors.New("the agent's policy has no project line")
	}
	return fmt.Errorf("the project's policy needs the line %q", "project "+src.Project)
}

// With is l with f in place of src, one of l's sources, and the stacks
// expanded again.
func (s Store) With(l *Loaded, src Source, f *File) (*Loaded, error) {
	if err := src.Check(f); err != nil {
		return nil, err
	}
	updated := *l
	for i := range updated.Own {
		if updated.Own[i].Path == src.Path {
			updated.Own[i].File = f
		}
		if updated.Agent[i].Path == src.Path {
			updated.Agent[i].File = f
		}
	}
	override := map[string]*File{}
	for _, m := range []Mode{SafeMode, UnsafeMode} {
		override[AgentPolicy(m)] = updated.Agent[m].File
	}
	if err := s.expand(&updated, override); err != nil {
		return nil, err
	}
	return &updated, nil
}

// expand fills l's stacks from its own policies, with override's policies,
// by name, in place of what s has.
func (s Store) expand(l *Loaded, override map[string]*File) error {
	for _, m := range []Mode{SafeMode, UnsafeMode} {
		ld := &namedLoader{dirs: s.Dirs, builtin: s.Builtin, seen: map[string]bool{}, override: override}
		var err error
		if l.Stacks[m], err = ld.expand(l.Own[m].File, l.Own[m].Path); err != nil {
			return err
		}
	}
	return nil
}

// Compile compiles the project's policies, as a Pair.
func (l *Loaded) Compile(floor Floor, env Env) (Pair, error) {
	var cs [2]*Compiled
	for _, m := range []Mode{SafeMode, UnsafeMode} {
		var err error
		if cs[m], err = Compile(l.Layer(m), floor, env); err != nil {
			return Pair{}, err
		}
	}
	return Bind(cs[SafeMode], cs[UnsafeMode])
}

// Layer is the policy for a mode, ready to compile.
func (l *Loaded) Layer(m Mode) Layer {
	return Layer{Name: m.String() + " policy", File: l.Stacks[m]}
}

// Load reads a project's policies. A missing one is what Create would
// write. An invalid file is an error: it must be fixed, never
// silently ignored or replaced with something else.
func (s Store) Load(project string) (*Loaded, error) {
	l := &Loaded{}
	for _, m := range []Mode{SafeMode, UnsafeMode} {
		var err error
		if l.Own[m], err = s.own(project, m); err != nil {
			return nil, err
		}
		if l.Agent[m], err = s.agent(m); err != nil {
			return nil, err
		}
	}
	if err := s.expand(l, nil); err != nil {
		return nil, err
	}
	return l, nil
}

// own reads a project's policy for a mode.
func (s Store) own(project string, m Mode) (Source, error) {
	src := Source{Name: "the project's " + m.String() + " policy", Path: filepath.Join(ProjectDir(s.ConfigDir, project), m.String()+".policy"), Mode: m, Project: project}
	data, err := os.ReadFile(src.Path)
	switch {
	case errors.Is(err, fs.ErrNotExist):
		if data, err = newProject(project, m); err != nil {
			return Source{}, err
		}
	case err != nil:
		return Source{}, err
	default:
		src.Exists = true
	}
	if src.File, err = Parse(data); err != nil {
		return Source{}, fmt.Errorf("%s: %w", src.Path, err)
	}
	if src.File.Directory != project {
		return Source{}, fmt.Errorf("%s is the policy for %q, not %q", src.Path, src.File.Directory, project)
	}
	return src, nil
}

// agent reads the agent's named policy for a mode: the user's, or else
// the one installed or built in, which an edit copies to the user's.
func (s Store) agent(m Mode) (Source, error) {
	name := AgentPolicy(m)
	src := Source{Name: "policy " + name, Path: filepath.Join(s.Dirs[0], name+".policy"), Mode: m}
	data, where, err := readNamed(s.Dirs, s.Builtin, name)
	if err != nil {
		return Source{}, err
	}
	src.Exists = where == src.Path
	if src.File, err = Parse(data); err != nil {
		return Source{}, fmt.Errorf("%s: %w", where, err)
	}
	return src, nil
}

// newProject is a new project policy for a mode: the project line, and
// the agent's policy for the mode, which the rules after it can change.
func newProject(project string, m Mode) ([]byte, error) {
	if err := (&File{Directory: project}).Validate(); err != nil {
		return nil, err
	}
	about := `# This project's safe policy: what Safe_Bash, and the file tools without
# asking, get. It starts from agent-safe; the rules after the include win
# where they match.`
	if m == UnsafeMode {
		about = `# This project's unsafe policy: what Unsafe_Bash, and the file tools once
# you approve, get. It starts from agent-unsafe; the rules after the
# include win where they match.`
	}
	return []byte(fmt.Sprintf(`%s
project %s

%s
include %s
`, about, project, header, AgentPolicy(m))), nil
}

// Save writes a policy file.
func Save(path string, f *File) error {
	if err := f.Validate(); err != nil {
		return err
	}
	return config.WriteFile(path, f.Marshal(), 0o600)
}

// Create writes the policies blinker edits for a project that are not
// written yet, as they apply now, and returns their paths, safe first:
// the project's own, or with agent, agent-safe and agent-unsafe.
func (s Store) Create(project string, agent bool) ([2]string, error) {
	l, err := s.Load(project)
	if err != nil {
		return [2]string{}, err
	}
	srcs := l.Own
	if agent {
		srcs = l.Agent
	}
	var paths [2]string
	for i, src := range srcs {
		paths[i] = src.Path
		if src.Exists {
			continue
		}
		if _, err := os.Stat(src.Path); err == nil {
			continue // written in the meantime
		}
		if err := Save(src.Path, src.File); err != nil {
			return [2]string{}, err
		}
	}
	return paths, nil
}

// storeMu serializes this process's read-modify-write updates of policy
// files, so a rule added when the user allows a host and one added by
// /show cannot overwrite each other.
var storeMu sync.Mutex

// AppendNetRule adds a network rule to the end of a project's unsafe
// policy, so it takes precedence, creating the policy if needed.
func (s Store) AppendNetRule(project string, r NetRule) (string, error) {
	storeMu.Lock()
	defer storeMu.Unlock()
	l, err := s.Load(project)
	if err != nil {
		return "", err
	}
	src := l.Own[UnsafeMode]
	f, err := src.File.rewrite(nil, r.Lines())
	if err != nil {
		return "", err
	}
	return src.Path, Save(src.Path, f)
}

// Replace saves f in place of one of a project's policies or the agent's
// (the one at path), unless that changed since it was read as was (its
// Marshal output): saving would lose the change.
func (s Store) Replace(project, path string, was []byte, f *File) error {
	storeMu.Lock()
	defer storeMu.Unlock()
	l, err := s.Load(project)
	if err != nil {
		return err
	}
	for _, src := range append(l.Own[:], l.Agent[:]...) {
		if src.Path != path {
			continue
		}
		if !bytes.Equal(src.File.Marshal(), was) {
			return fmt.Errorf("%s changed in the meantime; nothing was saved", path)
		}
		return Save(path, f)
	}
	return fmt.Errorf("%s is none of this project's policies", path)
}

// RulePath turns a path the user typed (relative to the project, under
// "~/" or absolute, and possibly climbing with "..") into a plain rule
// path, with the symlinks of its existing part resolved, as the kernel
// sees it: relative inside the project, "~/" under home, else absolute.
func RulePath(typed string, env Env) (string, error) {
	if typed == "" {
		return "", errors.New("empty path")
	}
	if IsGlob(typed) {
		return "", fmt.Errorf("%q is a pattern; use /policy for patterns", typed)
	}
	if strings.HasPrefix(typed, "~") && typed != "~" && !strings.HasPrefix(typed, "~/") {
		return "", fmt.Errorf("~user paths are not supported: %q", typed)
	}
	p := ResolveExisting(Expand(typed, env.Home, env.Project))
	switch {
	case p == env.Project:
		p = "."
	case Under(p, env.Project):
		p = strings.TrimPrefix(p, env.Project+"/")
	case p == env.Home:
		p = "~"
	case Under(p, env.Home):
		p = "~/" + strings.TrimPrefix(p, env.Home+"/")
	}
	if IsGlob(p) {
		return "", fmt.Errorf("%q has glob characters in its real path", typed)
	}
	return p, ValidatePath(p)
}

// Shown is what /show or /hide did: the rule's path, and the access each
// of the agent's policies now gives it.
type Shown struct {
	Path         string
	Safe, Unsafe Perm
}

// Show makes a path (as typed; see RulePath) readable without asking,
// with a rule appended to the project's safe policy, replacing its rule
// for the same path, and to its unsafe policy too if that gave less.
// Narrower rules inside the path keep deciding what is inside it, so
// showing a directory never reveals a secret in it that a rule hides: the
// project's are moved after the new rule, and those it includes copied
// there.
func (s Store) Show(env Env, typed string) (Shown, error) {
	return s.setPath(env, typed, true)
}

// Hide makes a path (as typed; see RulePath) and everything in it
// unreadable in both modes, with a rule appended to each of the project's
// policies. The rules of theirs it supersedes, for the path and anything
// inside it, are deleted.
func (s Store) Hide(env Env, typed string) (Shown, error) {
	return s.setPath(env, typed, false)
}

func (s Store) setPath(env Env, typed string, show bool) (Shown, error) {
	storeMu.Lock()
	defer storeMu.Unlock()
	rulePath, err := RulePath(typed, env)
	if err != nil {
		return Shown{}, err
	}
	l, err := s.Load(env.Project)
	if err != nil {
		return Shown{}, err
	}
	target, err := NewMatcher(rulePath, env.Home, env.Project)
	if err != nil {
		return Shown{}, err
	}
	if show {
		if _, err := os.Stat(target.Path); err != nil {
			return Shown{}, err
		}
	}
	pair, err := l.Compile(Floor{}, env)
	if err != nil {
		return Shown{}, err
	}
	date := time.Now().Format("2006-01-02")
	done := Shown{Path: rulePath}
	files := [2]*File{}
	for _, mode := range []Mode{SafeMode, UnsafeMode} {
		newRule := FileRule{Path: rulePath, Access: None, Note: "Hidden with /hide, " + date}
		if show {
			newRule.Access, newRule.Note = Read, "Shown with /show, "+date
			if a := pair.Unsafe.Access(target.Path); mode == UnsafeMode && a.Read {
				// Unsafe mode can read it already, and may write it.
				done.Unsafe = Read
				if a.Write {
					done.Unsafe = Write
				}
				continue
			}
		}
		if files[mode], err = setRule(l.Own[mode].File, l.Stacks[mode], env, newRule, target, show); err != nil {
			return Shown{}, err
		}
		if mode == SafeMode {
			done.Safe = newRule.Access
		} else {
			done.Unsafe = newRule.Access
		}
	}
	// Both are checked before either is saved.
	for mode, f := range files {
		if f == nil {
			continue
		}
		if err := Save(l.Own[mode].Path, f); err != nil {
			return Shown{}, err
		}
	}
	return done, nil
}

// setRule is own, a project's policy, with newRule for the plain path
// target at the end, followed by the rules it must not override, each
// with its note; stack is own with what it includes.
func setRule(own, stack *File, env Env, newRule FileRule, target Matcher, show bool) (*File, error) {
	add := newRule.Lines()
	if show {
		for _, r := range stack.Files {
			if r.From == "" || IsEnvPath(r.Path) {
				continue // own's, below; or its directories depend on the environment, not on this path
			}
			rm, err := NewMatcher(r.Path, env.Home, env.Project)
			if err != nil {
				return nil, err
			}
			if err := hidesInside(r, rm, newRule, target); err != nil {
				return nil, err
			}
			if inside(rm, target) {
				add = append(add, r.Lines()...)
			}
		}
	}
	var drop []int
	for _, r := range own.Files {
		if IsEnvPath(r.Path) {
			continue
		}
		rm, err := NewMatcher(r.Path, env.Home, env.Project)
		if err != nil {
			return nil, err
		}
		if show {
			if err := hidesInside(r, rm, newRule, target); err != nil {
				return nil, err
			}
		}
		switch {
		case !rm.IsGlob() && rm.Path == target.Path:
			// The same path: the new rule replaces it.
			drop = append(drop, r.Line)
		case show && inside(rm, target):
			drop = append(drop, r.Line)
			add = append(add, ruleBlock(own, r.Line, r.Lines())...)
		case !show && target.Covers(rm):
			// Inside the hidden path: it could never apply again.
			drop = append(drop, r.Line)
		}
	}
	return own.rewrite(drop, add)
}

// hidesInside refuses to show p when a pattern that gives less than
// newRule matches p itself and may match paths inside it too
// ("**/.env*" for ".envs" holding ".env.prod"). Copied after the new rule
// it would hide p again; left before it, what it hides inside p would
// show.
func hidesInside(r FileRule, rm Matcher, newRule FileRule, p Matcher) error {
	if !rm.IsGlob() || !rm.Match(p.Path) || r.Access >= newRule.Access {
		return nil
	}
	if Under(rm.Prefix, p.Path) || Under(p.Path, rm.Prefix) {
		return fmt.Errorf("%q is hidden by the rule for %q, which may also hide paths inside it; edit the policy with /policy instead", p.Path, r.Path)
	}
	return nil
}

// inside reports whether rule matcher r may match something strictly
// inside the plain path p, but not p itself.
func inside(r, p Matcher) bool {
	if r.Match(p.Path) {
		return false
	}
	if !r.IsGlob() {
		return Under(r.Path, p.Path)
	}
	return Under(r.Prefix, p.Path) || Under(p.Path, r.Prefix)
}

// Text shows the policies blinker edits for people and the model: each
// file's path and contents, the project's first.
func (l *Loaded) Text() string {
	var b strings.Builder
	for _, src := range append(l.Own[:], l.Agent[:]...) {
		state := ""
		if !src.Exists {
			state = " (no file yet: this applies)"
		}
		fmt.Fprintf(&b, "%s, %s%s:\n", src.Name, src.Path, state)
		b.Write(src.File.Marshal())
		b.WriteByte('\n')
	}
	return strings.TrimSuffix(b.String(), "\n")
}

// Rules shows the rules of the policy for a mode in order, with what it
// includes, each with where it is from.
func (l *Loaded) Rules(m Mode) string {
	var lines []string
	from := func(at string, line int) string {
		if at != "" {
			return at
		}
		return fmt.Sprintf("line %d", line)
	}
	f := l.Stacks[m]
	for _, r := range f.Files {
		lines = append(lines, fmt.Sprintf("file %s %s  (%s)", r.Access, r.Path, from(r.From, r.Line)))
	}
	for _, r := range f.Network {
		lines = append(lines, fmt.Sprintf("host %s %s  (%s)", r.Access, r.Host, from(r.From, r.Line)))
	}
	for _, r := range f.Tools {
		lines = append(lines, fmt.Sprintf("tool %s %s  (%s)", r.Access, r.Tool, from(r.From, r.Line)))
	}
	for _, r := range f.Env {
		if r.Value != "" {
			lines = append(lines, fmt.Sprintf("env set %s %s  (%s)", r.Name, r.Value, from(r.From, r.Line)))
			continue
		}
		lines = append(lines, fmt.Sprintf("env %s %s  (%s)", r.Access, r.Name, from(r.From, r.Line)))
	}
	return strings.Join(lines, "\n")
}

// ruleBlock is the rule on a file's line n as written there, with its
// note, or lines if the file was not read from text.
func ruleBlock(f *File, n int, lines []string) []string {
	if f.text == nil || n < 1 || n > len(f.text) {
		return lines
	}
	return slices.Clone(f.text[f.noteStart(n):n])
}