Download
274 lines 10.3 KiBGoModified 2026-10-31 raw
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)
		}
	}
}