123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 | package policy
import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestParsePolicy(t *testing.T) {
f, err := Parse([]byte("# type access pattern\nfile write .\n\n host ask example.com\nenv allow GO*\nfile hide a b\n"))
if err != nil {
t.Fatal(err)
}
if len(f.Files) != 2 || f.Files[0] != (FileRule{Path: ".", Access: Write, Line: 2}) || f.Files[1].Path != "a b" || f.Files[1].Access != None {
t.Errorf("files %+v", f.Files)
}
if len(f.Network) != 1 || f.Network[0].Access != Ask || f.Network[0].Line != 4 {
t.Errorf("network %+v", f.Network)
}
if len(f.Env) != 1 || f.Env[0].Access != Allow {
t.Errorf("env %+v", f.Env)
}
for in, want := range map[string]string{
"file read\n": "line 1: a rule needs three columns",
"type access pattern\n": "line 1: unknown rule type",
"file maybe .\n": "line 1: file access must be",
"env allow A\x01\n": "illegal character",
"env ask A\n": "allow, deny or set",
"tool ask Commit\n": "allow or deny",
} {
if _, err := Parse([]byte(in)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: got %v, want %q", in, err, want)
}
}
for _, name := range []string{"", ".hidden", "a/b", "..", "a b", strings.Repeat("x", 65)} {
if ValidName(name) == nil {
t.Errorf("policy name %q accepted", name)
}
}
if err := ValidName("safe-1.go_x"); err != nil {
t.Error(err)
}
}
// Every policy blinker is built with parses and compiles; one in a
// directory comes first.
func TestBuiltinPolicies(t *testing.T) {
configDir := t.TempDir()
dirs := NamedDirs(configDir, []string{"/nonexistent"})
builtin := os.DirFS("../../policies") // what blinker/policies embeds
names := NamedPolicies(dirs, builtin)
if !slices.Equal(names, []string{"agent-safe", "agent-unsafe", "build", "hide-secrets", "read", "toolchains", "web", "write"}) {
t.Errorf("policies %v", names)
}
for _, name := range names {
f, err := LoadNamed(dirs, builtin, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if _, err := Compile(Layer{"policy " + name, f}, DefaultFloor(configDir), Env{Home: "/home/u", Project: "/home/u/p"}); err != nil {
t.Errorf("%s: %v", name, err)
}
if len(f.Network) > 0 && name != "build" && name != "web" && name != "agent-unsafe" { // the registries, the web, and asking
t.Errorf("%s reaches the network", name)
}
}
read, err := LoadNamed(dirs, builtin, "read")
if err != nil || slices.ContainsFunc(read.Files, func(r FileRule) bool { return r.Access == Write }) {
t.Errorf("read writes: %+v, %v", read, err)
}
if _, err := LoadNamed(dirs, os.DirFS(t.TempDir()), "read"); err == nil || !strings.Contains(err.Error(), "there are none") {
t.Errorf("no policies: %v", err)
}
if err := os.MkdirAll(dirs[0], 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dirs[0], "read.policy"), []byte("host allow example.com\n"), 0o600); err != nil {
t.Fatal(err)
}
if f, err := LoadNamed(dirs, builtin, "read"); err != nil || len(f.Files) != 0 || len(f.Network) != 1 {
t.Errorf("the user's read: %+v, %v", f, err)
}
if _, err := LoadNamed(dirs, builtin, "nope"); err == nil || !strings.Contains(err.Error(), "there are agent-safe, agent-unsafe, build, hide-secrets, read, toolchains, web, write") {
t.Errorf("a missing policy: %v", err)
}
if _, err := LoadNamed(dirs, builtin, "../read"); err == nil {
t.Error("loaded ../read")
}
}
// Every command's policy blinker is built with loads, with what it
// includes, and compiles.
func TestBuiltinCommandPolicies(t *testing.T) {
configDir := t.TempDir()
dirs := NamedDirs(configDir, []string{"/nonexistent"})
builtin := os.DirFS("../../policies")
cdirs, cfs, err := CommandPolicyDirs(dirs, builtin)
if err != nil {
t.Fatal(err)
}
names := NamedPolicies(cdirs, cfs)
if !slices.Contains(names, "claude") || slices.Contains(names, "build") {
t.Errorf("command policies %v", names)
}
for _, name := range names {
f, err := LoadCommandPolicy(dirs, builtin, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if _, err := Compile(Layer{"policy " + name, f}, DefaultFloor(configDir), Env{Home: "/home/u", Project: "/home/u/p"}); err != nil {
t.Errorf("%s: %v", name, err)
}
}
if _, err := LoadCommandPolicy(dirs, builtin, "build"); !errors.Is(err, ErrNoPolicy) {
t.Errorf("a policy that is not a command's: %v", err)
}
}
// An include line stands for the included policy's rules where it is, so
// the rules after it win where both match. Includes may be nested, each
// policy is included once, and none may lead back to a policy it is in.
func TestPolicyInclude(t *testing.T) {
dirs := []string{t.TempDir()}
builtin := os.DirFS(t.TempDir())
write := func(name, text string) {
t.Helper()
p := filepath.Join(dirs[0], name)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
hosts := func(f *File) []string {
var out []string
for _, r := range f.Network {
out = append(out, r.Host)
}
return out
}
write("base.policy", "file write .\nhost allow base.example\n")
write("commands/tool.policy", "include base\nfile write ~/.cache/tool/\nhost allow tool.example\n")
write("commands/other.policy", "include base\nhost allow other.example\n")
write("mid.policy", "file read ~/.cache\nhost allow before.example\ninclude commands/tool\ninclude commands/other\nhost allow after.example\n")
write("commands/top.policy", "# A tool.\n\n# type access pattern\ninclude mid\nfile read .\n")
f, err := LoadCommandPolicy(dirs, builtin, "top")
if err != nil {
t.Fatal(err)
}
// base is included once, by tool, where tool includes it.
if got := hosts(f); !slices.Equal(got, []string{"before.example", "base.example", "tool.example", "other.example", "after.example"}) {
t.Errorf("hosts %q", got)
}
var paths []string
for _, r := range f.Files {
paths = append(paths, r.Path)
}
// The included ~/.cache/tool/ makes no directory, and comes after
// mid's ~/.cache, so it wins there.
if !slices.Equal(paths, []string{"~/.cache", ".", "~/.cache/tool", "."}) {
t.Errorf("paths %q", paths)
}
// Included rules say where they are from, and are no lines of the
// file that includes them.
if r := f.Files[2]; r.From != "policy commands/tool line 2" || r.Line != 0 || f.Files[3].From != "" || f.Files[3].Line != 5 {
t.Errorf("where the rules are from: %+v", f.Files)
}
write("commands/top.policy", "include mid\nproject /p\n")
write("mid.policy", "project /p\n")
if _, err := LoadCommandPolicy(dirs, builtin, "top"); err == nil || !strings.Contains(err.Error(), "no project line") {
t.Errorf("a named policy with a project line: %v", err)
}
write("mid.policy", "file read ~/.cache\nhost allow before.example\ninclude commands/tool\ninclude commands/other\nhost allow after.example\n")
write("commands/top.policy", "# A tool.\n\n# type access pattern\ninclude mid\nfile read .\n")
for name, text := range map[string]string{
"commands/tool.policy": "include commands/tool\n",
"base.policy": "include mid\n",
"commands/other.policy": "include commands/top\n",
} {
old, err := os.ReadFile(filepath.Join(dirs[0], name))
if err != nil {
t.Fatal(err)
}
write(name, text)
if _, err := LoadCommandPolicy(dirs, builtin, "top"); err == nil || !strings.Contains(err.Error(), "round in a circle") {
t.Errorf("%s %q: %v", name, text, err)
}
write(name, string(old))
}
for text, want := range map[string]string{
"include\n": "one policy",
"include a b\n": "one policy",
"include ../base\n": "bad policy name",
"include commands/../x\n": "bad policy name",
"include nope\n": "no policy",
"include commands/nope\n": "no policy",
} {
write("commands/top.policy", text)
if _, err := LoadCommandPolicy(dirs, builtin, "top"); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: %v, want %q", text, err, want)
}
}
}
// "env set NAME VALUE" sets a variable for commands, but never one the
// floor keeps from them, and only by name.
func TestNamedEnvSet(t *testing.T) {
f, err := Parse([]byte("env set A ~/x y\nenv allow B\nenv set A /z\n"))
if err != nil {
t.Fatal(err)
}
c, err := Compile(Layer{"policy", f}, Floor{}, Env{Home: "/home/u", Project: "/home/u/p"})
if err != nil {
t.Fatal(err)
}
if got := c.EnvValues(); !slices.Equal(got, []string{"A=/z"}) {
t.Errorf("values %q", got)
}
if f, err = Parse([]byte("env set A ~/x y\n")); err != nil {
t.Fatal(err)
}
if c, err = Compile(Layer{"policy", f}, Floor{}, Env{Home: "/home/u", Project: "/home/u/p"}); err != nil || !slices.Equal(c.EnvValues(), []string{"A=/home/u/x y"}) {
t.Errorf("values %q, %v", c.EnvValues(), err)
}
for text, want := range map[string]string{
"env set A\n": "name and its value",
"env set LD_PRELOAD /x\n": "never available",
"env set GO* /x\n": "not a pattern",
"env set A x\u202ey\n": "illegal character",
"env set BLINKER_MODE x\n": "never available",
} {
if _, err := Parse([]byte(text)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: %v, want %q", text, err, want)
}
}
}
// "a+b" is a's rules, then b's, each read once; a name with no policy
// of its own is a command's.
func TestPolicyUnion(t *testing.T) {
dirs := []string{t.TempDir()}
builtin := os.DirFS("../../policies")
f, err := LoadNamed(dirs, builtin, "write+web")
if err != nil {
t.Fatal(err)
}
if len(f.Files) == 0 || f.Files[0].Path != "." || f.Files[0].Access != Write || len(f.Network) != 1 || f.Network[0].Host != "*" {
t.Errorf("write+web: %+v", f)
}
g, err := LoadNamed(dirs, builtin, "go+rustc+write")
if err != nil {
t.Fatal(err)
}
var paths []string
for _, r := range g.Files {
paths = append(paths, r.Path)
}
if n := slices.Index(paths, "."); n < 0 || slices.Index(paths[n+1:], ".") >= 0 {
t.Errorf("write's rules not once: %q", paths)
}
if !slices.Contains(paths, "${RUSTUP_HOME:-~/.rustup}") || !slices.Contains(paths, "${GOPATH:-~/go}/pkg/") {
t.Errorf("go+rustc: %q", paths)
}
for name, want := range map[string]string{"write+": "bad policy name", "write+nope": `nor one for a command named "nope"`, "commands/write": "no policy"} {
if _, err := LoadNamed(dirs, builtin, name); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", name, err, want)
}
}
}
|