Download
605 lines 16.9 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
// Package policy reads and evaluates blinker's access policies: ordered
// lists of rules saying what a sandboxed command may do with files,
// network hosts and environment variables, and which of the agent's tools
// may run. For any of them, the last matching rule wins.
//
// A policy is a text file with one rule per line:
//
//	# type  access  pattern
//	include read
//	file    write   .
//	file    hide    **/.env*
//	host    allow   proxy.golang.org
//	env     allow   GOFLAGS
//	env     set     GOTOOLCHAIN local
//	tool    allow   Commit
//
// The pattern is the rest of the line, so it may hold spaces, and the
// columns are separated by spaces or tabs. Lines starting with "#" are
// comments; the ones directly above a rule are its note. "include NAME"
// stands for another policy's rules, where the line is (see LoadNamed).
// A project's policy has a "project DIRECTORY" line.
//
// The agent has two policies for a project: the safe one, for Safe_Bash
// and the file tools without asking, and the unsafe one, for Unsafe_Bash
// and the file tools once the user approves. blinker, the sandbox
// command, runs a program under one policy.
package policy

import (
	"errors"
	"fmt"
	"path"
	"slices"
	"strings"
	"unicode"
	"unicode/utf8"
)

// Perm is the access a rule gives to files: hide, read, or write (which
// includes read). The kernel enforces exactly these three states.
type Perm int

const (
	None Perm = iota
	Read
	Write
)

var permNames = []string{"hide", "read", "write"}

func (p Perm) String() string { return permNames[p] }

func parsePerm(s string) (Perm, error) {
	if i := slices.Index(permNames, s); i >= 0 {
		return Perm(i), nil
	}
	return 0, fmt.Errorf("file access must be hide, read or write, not %q", s)
}

// NetPerm is what a rule does with connections to a host: deny, ask the
// user, or allow. Where nobody can be asked, ask denies. It is also an
// env or tool rule's access, which is deny or allow.
type NetPerm int

const (
	Deny NetPerm = iota
	Ask
	Allow
)

var netPermNames = []string{"deny", "ask", "allow"}

func (p NetPerm) String() string { return netPermNames[p] }

func parseNetPerm(s string) (NetPerm, error) {
	if i := slices.Index(netPermNames, s); i >= 0 {
		return NetPerm(i), nil
	}
	return 0, fmt.Errorf("access must be deny, ask or allow, not %q", s)
}

// FileRule gives the files a path glob matches, and everything inside
// them, an access.
type FileRule struct {
	Path   string
	Access Perm
	Note   string // the comment directly above the rule
	Line   int    // the rule's line in its file, from 1; 0 if not from one
	// From is where a rule another policy included is, such as "policy
	// build line 3"; "" for the file's own.
	From string
}

// NetRule gives the hosts a pattern matches an access.
type NetRule struct {
	Host   string
	Access NetPerm
	Note   string
	Line   int
	From   string
}

// EnvRule says whether the environment variables a name glob matches are
// passed to commands: allow or deny, never ask.
type EnvRule struct {
	Name   string
	Access NetPerm
	// Value is what an "env set" rule gives the variable, in place of
	// blinker's own value; "" passes blinker's on.
	Value string
	Note  string
	Line  int
	From  string
}

// File is one policy file.
type File struct {
	// Directory is the project a project's policy is for; empty for any
	// other.
	Directory string
	// Includes are the file's "include" lines (see LoadNamed).
	Includes []Include
	Files    []FileRule
	Network  []NetRule
	Tools    []ToolRule
	Env      []EnvRule
	// text is the file's lines, comments included, when it was parsed
	// from one. Its rules are not changed in place, only by editing the
	// text and parsing it again (see rewrite), so the two always agree.
	text []string
}

// Include is an "include NAME" line.
type Include struct {
	Name string // a policy's, or commands/COMMAND
	Line int
}

// header labels the columns. It is a comment, but never a rule's note.
const header = "# type  access  pattern"

func isHeader(comment string) bool {
	c, ok := strings.CutPrefix(comment, "#")
	return ok && slices.Equal(strings.Fields(c), []string{"type", "access", "pattern"})
}

// field splits off the first word of s, and the rest after the spaces or
// tabs that follow it.
func field(s string) (word, rest string) {
	s = strings.TrimLeft(s, " \t")
	i := strings.IndexAny(s, " \t")
	if i < 0 {
		return s, ""
	}
	return s[:i], strings.TrimLeft(s[i:], " \t")
}

// Parse reads a policy file. Anything malformed is an error: a policy is
// never applied in part.
func Parse(data []byte) (*File, error) {
	if !utf8.Valid(data) {
		return nil, errors.New("not valid UTF-8")
	}
	f := &File{text: []string{}}
	if s := strings.TrimSuffix(string(data), "\n"); s != "" {
		f.text = strings.Split(s, "\n")
	}
	var note []string
	for i, line := range f.text {
		lineNum := i + 1
		fail := func(format string, args ...any) (*File, error) {
			return nil, fmt.Errorf("line %d: %s", lineNum, fmt.Sprintf(format, args...))
		}
		if c, bad := illegalChar(line); bad {
			return fail("illegal character %q", c)
		}
		trimmed := strings.TrimLeft(line, " \t")
		switch {
		case trimmed == "", isHeader(trimmed):
			note = nil
			continue
		case strings.HasPrefix(trimmed, "#"):
			if c := strings.TrimSpace(trimmed[1:]); c != "" {
				note = append(note, c)
			}
			continue
		}
		if strings.HasSuffix(line, " ") || strings.HasSuffix(line, "\t") {
			return fail("the line ends with a space; a pattern cannot (use [ ] to match a space at the end)")
		}
		kind, rest := field(trimmed)
		ruleNote := strings.Join(note, " ")
		note = nil
		switch kind {
		case "project":
			if f.Directory != "" {
				return fail("a second project line")
			}
			if !strings.HasPrefix(rest, "/") {
				return fail("the project line needs the project's absolute directory")
			}
			f.Directory = rest
			continue
		case "include":
			name, more := field(rest)
			if name == "" || more != "" {
				return fail("include takes one policy's name")
			}
			if err := ValidName(strings.TrimPrefix(name, CommandDir+"/")); err != nil {
				return fail("%v", err)
			}
			f.Includes = append(f.Includes, Include{Name: name, Line: lineNum})
			continue
		}
		access, pattern := field(rest)
		if pattern == "" {
			return fail("a rule needs three columns: type, access and pattern")
		}
		var err error
		switch kind {
		case "file":
			r := FileRule{Path: pattern, Note: ruleNote, Line: lineNum}
			r.Access, err = parsePerm(access)
			f.Files = append(f.Files, r)
		case "host":
			r := NetRule{Host: pattern, Note: ruleNote, Line: lineNum}
			r.Access, err = parseNetPerm(access)
			f.Network = append(f.Network, r)
		case "env":
			r := EnvRule{Name: pattern, Note: ruleNote, Line: lineNum}
			if access == "set" {
				r.Access = Allow
				r.Name, r.Value = field(pattern)
				if r.Value == "" {
					err = errors.New("env set needs a variable's name and its value")
				}
			} else if r.Access, err = parseNetPerm(access); err == nil && r.Access == Ask {
				err = errors.New("an env rule's access is allow, deny or set")
			}
			f.Env = append(f.Env, r)
		case "tool":
			r := ToolRule{Tool: pattern, Note: ruleNote, Line: lineNum}
			if r.Access, err = parseNetPerm(access); err == nil && r.Access == Ask {
				err = errors.New("a tool rule's access is allow or deny: the safe policy's tools run without asking, and the unsafe policy's once the user approves")
			}
			f.Tools = append(f.Tools, r)
		default:
			return fail("unknown rule type %q: use file, host, env, tool, include or project", kind)
		}
		if err != nil {
			return fail("%v", err)
		}
	}
	return f, f.Validate()
}

// where names a rule in messages: its line, or for a rule not read from a
// file, its place among the rules of its type.
func where(kind string, i, line int) string {
	if line > 0 {
		return fmt.Sprintf("line %d", line)
	}
	return fmt.Sprintf("%s rule %d", kind, i+1)
}

// Validate checks the project line and every rule.
func (f *File) Validate() error {
	// The project line must read back as written: a directory with a
	// newline in its name would otherwise put lines of its own in the file.
	if d := f.Directory; d != "" {
		if !strings.HasPrefix(d, "/") || strings.HasSuffix(d, " ") || strings.HasSuffix(d, "\t") {
			return fmt.Errorf("project directory %q cannot be written on a project line", d)
		}
		if c, bad := illegalChar(d); bad {
			return fmt.Errorf("illegal character %q in project directory %q", c, d)
		}
	}
	for i, r := range f.Tools {
		if err := r.check(); err != nil {
			return fmt.Errorf("%s: %w", where("tool", i, r.Line), err)
		}
	}
	for i, r := range f.Files {
		if err := r.check(); err != nil {
			return fmt.Errorf("%s: %w", where("file", i, r.Line), err)
		}
	}
	for i := range f.Network {
		if err := f.Network[i].check(); err != nil {
			return fmt.Errorf("%s: %w", where("host", i, f.Network[i].Line), err)
		}
	}
	for i, r := range f.Env {
		if err := r.check(); err != nil {
			return fmt.Errorf("%s: %w", where("env", i, r.Line), err)
		}
	}
	return nil
}

func (r ToolRule) check() error {
	if r.Tool != "*" {
		if err := ValidToolName(r.Tool); err != nil {
			return err
		}
	}
	if r.Access != Deny && r.Access != Allow {
		return fmt.Errorf("%s: bad access", r.Tool)
	}
	return nil
}

func (r FileRule) check() error {
	if err := ValidatePath(r.Path); err != nil {
		return err
	}
	if r.Access < None || r.Access > Write {
		return fmt.Errorf("%s: bad access", r.Path)
	}
	return nil
}

// check validates a network rule and puts its host in lower case.
func (r *NetRule) check() error {
	host, err := ValidateHost(r.Host)
	if err != nil {
		return err
	}
	r.Host = host
	if r.Access < Deny || r.Access > Allow {
		return fmt.Errorf("%s: bad access", r.Host)
	}
	return nil
}

func (r EnvRule) check() error {
	if err := ValidateEnvName(r.Name); err != nil {
		return err
	}
	if r.Value != "" {
		if !validVarName(r.Name) {
			return fmt.Errorf("%q: a variable to set is a name, not a pattern", r.Name)
		}
		if EnvFloored(r.Name) {
			return fmt.Errorf("%s is never available to commands", r.Name)
		}
		for _, c := range r.Value {
			if unicode.IsControl(c) || unicode.Is(unicode.Cf, c) {
				return fmt.Errorf("illegal character %U in the value of %s", c, r.Name)
			}
		}
		if IsVarPath(r.Value) {
			if err := validateVarPath(r.Value); err != nil {
				return fmt.Errorf("the value of %s: %w", r.Name, err)
			}
		}
	}
	if r.Access == Ask {
		return fmt.Errorf("%s: use allow or deny; the user approves the whole script in unsafe mode", r.Name)
	}
	if r.Access < Deny || r.Access > Allow || r.Value != "" && r.Access != Allow {
		return fmt.Errorf("%s: bad access", r.Name)
	}
	return nil
}

// ValidateEnvName checks an env rule's name: letters, digits and "_",
// with the glob characters "*", "?" and "[...]".
func ValidateEnvName(name string) error {
	if name == "" {
		return errors.New("empty variable name")
	}
	for _, c := range name {
		if !varChar(c) && !strings.ContainsRune("*?[]^-", c) {
			return fmt.Errorf("illegal character %q in variable name %q", c, name)
		}
	}
	if _, err := path.Match(name, ""); err != nil {
		return fmt.Errorf("bad glob %q: %w", name, err)
	}
	return nil
}

// validVarName reports whether name is a plain variable name.
func validVarName(name string) bool {
	if name == "" || name[0] >= '0' && name[0] <= '9' {
		return false
	}
	for _, c := range name {
		if !varChar(c) {
			return false
		}
	}
	return true
}

func varChar(c rune) bool {
	return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_'
}

// Marshal writes a policy file: the text it was parsed from, or for one
// built in code, its includes and then its rules one per line under the
// column header, each note a comment above its rule.
func (f *File) Marshal() []byte {
	lines := f.text
	if lines == nil {
		if f.Directory != "" {
			lines = append(lines, "project "+f.Directory, "")
		}
		lines = append(lines, header)
		for _, inc := range f.Includes {
			lines = append(lines, "include "+inc.Name)
		}
		for _, r := range f.Files {
			lines = append(lines, r.Lines()...)
		}
		for _, r := range f.Network {
			lines = append(lines, r.Lines()...)
		}
		for _, r := range f.Tools {
			lines = append(lines, r.Lines()...)
		}
		for _, r := range f.Env {
			lines = append(lines, r.Lines()...)
		}
	}
	if len(lines) == 0 {
		return nil
	}
	return []byte(strings.Join(lines, "\n") + "\n")
}

// ruleLines is a rule as it is written: its note, then the rule with its
// columns lined up under the header.
func ruleLines(note, kind, access, pattern string) []string {
	var out []string
	if note != "" {
		out = append(out, "# "+strings.ReplaceAll(note, "\n", " "))
	}
	return append(out, fmt.Sprintf("%-8s%-8s%s", kind, access, pattern))
}

// Lines is the rule as written in a policy file.
func (r FileRule) Lines() []string {
	return ruleLines(r.Note, "file", r.Access.String(), r.Path)
}

// Lines is the rule as written in a policy file.
func (r NetRule) Lines() []string {
	return ruleLines(r.Note, "host", r.Access.String(), r.Host)
}

// Lines is the rule as written in a policy file.
func (r EnvRule) Lines() []string {
	if r.Value != "" {
		return ruleLines(r.Note, "env", "set", r.Name+" "+r.Value)
	}
	return ruleLines(r.Note, "env", r.Access.String(), r.Name)
}

// Lines is the rule as written in a policy file.
func (r ToolRule) Lines() []string {
	return ruleLines(r.Note, "tool", r.Access.String(), r.Tool)
}

// rewrite is f with the rules on the given lines removed, each with its
// note, and lines added at the end. Comments elsewhere stay as they are.
func (f *File) rewrite(drop []int, add []string) (*File, error) {
	if f.text == nil {
		var err error
		if f, err = Parse(f.Marshal()); err != nil {
			return nil, err
		}
		if len(drop) > 0 {
			return nil, errors.New("cannot remove rules from a policy not read from a file")
		}
	}
	gone := map[int]bool{}
	for _, n := range drop {
		if n < 1 || n > len(f.text) {
			return nil, fmt.Errorf("no rule on line %d", n)
		}
		for i := f.noteStart(n); i < n; i++ {
			gone[i] = true
		}
	}
	var lines []string
	for i, line := range f.text {
		if !gone[i] {
			lines = append(lines, line)
		}
	}
	// A comment at the end would become the first added rule's note.
	if k := len(lines); len(add) > 0 && k > 0 {
		if last := strings.TrimLeft(lines[k-1], " \t"); strings.HasPrefix(last, "#") && !isHeader(last) {
			lines = append(lines, "")
		}
	}
	lines = append(lines, add...)
	return Parse([]byte(strings.Join(lines, "\n") + "\n"))
}

// noteStart is the index of the first line of the note of the rule on
// line n (from 1), or of the rule itself if it has none.
func (f *File) noteStart(n int) int {
	i := n - 1
	for i > 0 {
		above := strings.TrimLeft(f.text[i-1], " \t")
		if !strings.HasPrefix(above, "#") || isHeader(above) {
			break
		}
		i--
	}
	return i
}

// ValidateHost checks a network host pattern: a hostname, "*." and a
// domain for its subdomains, or "*" for everything. It returns it in lower
// case.
func ValidateHost(raw string) (string, error) {
	host := lowerASCII(strings.TrimSpace(raw))
	if host == "*" {
		return host, nil
	}
	for _, label := range strings.Split(strings.TrimPrefix(host, "*."), ".") {
		ok := label != ""
		for _, c := range label {
			ok = ok && (c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_')
		}
		if !ok {
			return "", fmt.Errorf("invalid host %q: use a hostname like github.com, *.example.com for its subdomains, or *", raw)
		}
	}
	return host, nil
}

// lowerASCII lowers A-Z only. Host names are ASCII; Unicode's case rules
// would turn some other letters into ASCII ones (the Kelvin sign into k,
// a dotless ı, uppercased, into I), so a host that is not a pattern's
// would match it.
func lowerASCII(s string) string {
	return strings.Map(func(r rune) rune {
		if r >= 'A' && r <= 'Z' {
			return r + 'a' - 'A'
		}
		return r
	}, s)
}

// HostMatches matches a host against a pattern: "*" matches everything,
// "*.example.com" its subdomains only (not example.com itself), and
// anything else exactly.
func HostMatches(host, pattern string) bool {
	host, pattern = lowerASCII(strings.TrimSuffix(host, ".")), lowerASCII(pattern)
	if pattern == "*" {
		return true
	}
	if base, ok := strings.CutPrefix(pattern, "*."); ok {
		// A subdomain has a name of its own before the dot.
		return len(host) > len(base)+1 && strings.HasSuffix(host, "."+base)
	}
	return host == pattern
}

// Mode is which of the agent's policies a command runs under: SafeMode
// runs without asking, under the safe policy; UnsafeMode runs once the
// user approves, under the unsafe one.
type Mode int

const (
	SafeMode Mode = iota
	UnsafeMode
)

func (m Mode) String() string {
	if m == UnsafeMode {
		return "unsafe"
	}
	return "safe"
}

// Level is how a file tool may do something: without asking, after the
// user confirms, or not at all.
type Level int

const (
	Safe Level = iota
	Confirm
	Forbid
)

func (l Level) String() string {
	return [...]string{"safe", "confirm", "forbidden"}[l]
}

// illegalChar finds a control character other than tab, which no line of
// a policy may hold.
func illegalChar(s string) (rune, bool) {
	for _, c := range s {
		if c < 0x20 && c != '\t' || c == 0x7f {
			return c, true
		}
	}
	return 0, false
}