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
}
|