Download
346 lines 12 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
package policy

import (
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
)

// realTemp is a temp dir with its symlinks resolved, as Env's paths are.
func realTemp(t *testing.T) string {
	t.Helper()
	dir, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	return dir
}

func compileIn(t *testing.T, env Env, floor Floor, text string) (*Compiled, error) {
	t.Helper()
	f, err := Parse([]byte(text))
	if err != nil {
		t.Fatal(err)
	}
	return Compile(Layer{Name: "project policy", File: f}, floor, env)
}

// Rule paths are globs over home and the project, so a wildcard in their
// own names would turn every rule into a pattern: "." in p[1] matching
// the sibling p1.
func TestCompileRefusesWildcardDirs(t *testing.T) {
	for _, env := range []Env{
		{Home: "/home/u", Project: "/home/u/p[1]"},
		{Home: "/home/*", Project: "/srv/p"},
		{Home: "/home/u", Project: "/home/u/p", Scratch: "/tmp/s?"},
		{Home: "/home/u", Project: "/home/u/p\u202e"},
	} {
		if _, err := compileIn(t, env, Floor{}, "file write .\n"); err == nil {
			t.Errorf("compiled in %+v", env)
		}
	}
}

// An absolute rule follows the symlinks the user meant, but not one a
// command could put there, and never one named like a wildcard.
func TestAbsoluteRuleSymlinks(t *testing.T) {
	root := realTemp(t)
	home, data, elsewhere := filepath.Join(root, "home"), filepath.Join(root, "data"), filepath.Join(root, "elsewhere")
	for _, d := range []string{home, filepath.Join(data, "a"), elsewhere} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	env := Env{Home: home, Project: filepath.Join(home, "proj")}

	// A file named "*" is not the wildcard's to follow.
	if err := os.Symlink(elsewhere, filepath.Join(data, "*")); err != nil {
		t.Fatal(err)
	}
	c, err := compileIn(t, env, Floor{}, "file read "+data+"\nfile hide "+data+"/*/secret\n")
	if err != nil {
		t.Fatal(err)
	}
	if a := c.Access(filepath.Join(data, "a", "secret")); a.Read {
		t.Errorf("the hide rule followed a link named *: %+v", a)
	}

	// A link where commands may write would redirect the rule at the
	// next start: here, a grant of read to home.
	if err := os.Symlink(home, filepath.Join(data, "ref")); err != nil {
		t.Fatal(err)
	}
	if _, err := compileIn(t, env, Floor{}, "file write "+data+"\nfile read "+data+"/ref\n"); err == nil || !strings.Contains(err.Error(), "symlink") {
		t.Errorf("followed a replaceable link: %v", err)
	}
	// Where no command may write, the user's own link is followed.
	c, err = compileIn(t, env, Floor{}, "file read "+data+"/ref\n")
	if err != nil {
		t.Fatal(err)
	}
	if !c.Access(filepath.Join(home, "x")).Read {
		t.Error("the user's link was not followed")
	}
	// Nor may commands under the agent's other policy replace it.
	unsafe, err := compileIn(t, env, Floor{}, "file write "+data+"\n")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Bind(c, unsafe); err == nil || !strings.Contains(err.Error(), "symlink") {
		t.Errorf("followed a link the unsafe policy lets commands replace: %v", err)
	}
}

// The floor's key patterns cover a project outside home too, and apply
// where the sandbox enforces them: in the project.
func TestFloorPatternsFollowTheProject(t *testing.T) {
	env := Env{Home: "/home/u", Project: "/srv/p"}
	c, err := compileIn(t, env, DefaultFloor(), "file write .\nfile read ~/Documents\n")
	if err != nil {
		t.Fatal(err)
	}
	for _, p := range []string{"/srv/p/server.pem", "/srv/p/deploy/id_rsa", "/srv/p/tls.key"} {
		if a := c.Access(p); a.Read || !a.Floor {
			t.Errorf("%s: %+v", p, a)
		}
	}
	// Outside the project the kernel does not look for them, so neither
	// does the evaluator: the two must agree.
	if a := c.Access("/home/u/Documents/server.pem"); !a.Read {
		t.Errorf("~/Documents/server.pem: %+v", a)
	}
}

// /show cannot reveal what a pattern hides inside the shown directory
// when the pattern hides the directory itself too.
func TestShowRefusesSelfMatchingPattern(t *testing.T) {
	home := realTemp(t)
	env := Env{Home: home, Project: filepath.Join(home, "proj")}
	if err := os.MkdirAll(filepath.Join(env.Project, ".envs"), 0o700); err != nil {
		t.Fatal(err)
	}
	s := testStore(t)
	if _, err := s.Show(env, ".envs"); err == nil {
		t.Fatal("showed .envs")
	}
	if a := pairOf(t, s, env).Safe.Access(filepath.Join(env.Project, ".envs", ".env.production")); a.Read {
		t.Errorf("revealed: %+v", a)
	}
}

func TestValidatePathCharacters(t *testing.T) {
	for _, p := range []string{" /etc", "/etc ", "\t.", ".env\u200b", "x\u202e", "a\u0085b", "a\nb"} {
		if ValidatePath(p) == nil {
			t.Errorf("ValidatePath(%q) passed", p)
		}
	}
	for _, p := range []string{"dir with spaces/x", "foo[ ]", "ünïcode/路径"} {
		if err := ValidatePath(p); err != nil {
			t.Errorf("ValidatePath(%q): %v", p, err)
		}
	}
}

// A rule under home follows the user's links (~/.vimrc to a dotfiles
// repo), unless a command could replace them; one in the project never
// does, as what is there is not the user's choice.
func TestHomeRuleSymlinks(t *testing.T) {
	home := realTemp(t)
	env := Env{Home: home, Project: filepath.Join(home, "proj")}
	dots := filepath.Join(home, "dotfiles")
	for _, d := range []string{dots, env.Project} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.Symlink(filepath.Join(dots, "vimrc"), filepath.Join(home, ".vimrc")); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(dots, filepath.Join(env.Project, "dots")); err != nil {
		t.Fatal(err)
	}
	c, err := compileIn(t, env, Floor{}, "file read ~/.vimrc\nfile read dots\n")
	if err != nil {
		t.Fatal(err)
	}
	if !c.Access(filepath.Join(dots, "vimrc")).Read {
		t.Error("the link in home was not followed")
	}
	if c.Access(filepath.Join(dots, "other")).Read {
		t.Error("the link in the project was followed")
	}
	if got := c.Links(); len(got) != 1 || got[0] != filepath.Join(home, ".vimrc") {
		t.Errorf("links: %v", got)
	}
	if _, err := compileIn(t, env, Floor{}, "file write ~\nfile read ~/.vimrc\n"); err == nil || !strings.Contains(err.Error(), "symlink") {
		t.Errorf("followed a replaceable link: %v", err)
	}
}

// A trailing "/" names a directory, and matches as the plain path does.
func TestDirRule(t *testing.T) {
	env := Env{Home: "/home/u", Project: "/home/u/p"}
	c, err := compileIn(t, env, Floor{}, "file write ~/.cache/x/\nfile read y\n")
	if err != nil {
		t.Fatal(err)
	}
	if !c.NamedDir("/home/u/.cache/x") || c.NamedDir("/home/u/p/y") || !c.Access("/home/u/.cache/x/z").Write {
		t.Errorf("%+v", c.rules)
	}
}

// A host denied by name is one the policy means to keep out; one denied
// by "*", or by no rule, is not.
func TestDeniedByName(t *testing.T) {
	c, err := compileIn(t, Env{Home: "/home/u", Project: "/home/u/p"}, Floor{}, "host deny *\nhost allow *.example.com\nhost deny t.example.com\n")
	if err != nil {
		t.Fatal(err)
	}
	for host, want := range map[string]bool{"t.example.com": true, "a.example.com": false, "other.org": false} {
		if got := c.DeniedByName(host); got != want {
			t.Errorf("%s: %v", host, got)
		}
	}
}

// The floor keeps what runs at login or in every session from being
// written, but leaves a program's own configuration to the policy.
func TestFloorConfig(t *testing.T) {
	c, err := compileIn(t, Env{Home: "/home/u", Project: "/home/u/p"}, DefaultFloor(), "file write ~/.config\n")
	if err != nil {
		t.Fatal(err)
	}
	for p, want := range map[string]bool{
		"/home/u/.config/claude/settings.json":   true,
		"/home/u/.config/systemd/user/x.service": false,
		"/home/u/.config/autostart/x.desktop":    false,
		"/home/u/.config/environment.d/x.conf":   false,
		"/home/u/.config/fish/config.fish":       false,
		"/home/u/.config/git/config":             false,
	} {
		if got := c.Access(p).Write; got != want {
			t.Errorf("%s: write %v", p, got)
		}
	}
}

// The agent's API keys reach no command unless the floor lets rules pass
// them, as only blinker's does; the rest of the env floor holds either way.
func TestAgentKeys(t *testing.T) {
	f, err := Parse([]byte("env allow OPENAI_API_KEY\nenv allow BLINKER_MODE\n"))
	if err != nil {
		t.Fatal(err)
	}
	env := Env{Home: "/home/u", Project: "/home/u/p"}
	for _, pass := range []bool{false, true} {
		c, err := Compile(Layer{"policy", f}, Floor{PassAgentKeys: pass}, env)
		if err != nil {
			t.Fatal(err)
		}
		if got := c.EnvAllowed("OPENAI_API_KEY"); got != pass {
			t.Errorf("PassAgentKeys %v: the key passes: %v", pass, got)
		}
		if c.EnvAllowed("BLINKER_MODE") {
			t.Errorf("PassAgentKeys %v: BLINKER_MODE passes", pass)
		}
	}
	if !EnvFloored("ANTHROPIC_API_KEY") {
		t.Error("EnvFloored, for what runs beside the agent, lets the key out")
	}
}

// "${NAME:-default}rest" names the variable's directory, or the default
// where the variable is unset or not one plain absolute path that leaves
// home and the project alone; the default may be another such.
func TestVarPaths(t *testing.T) {
	env := Env{Home: "/home/u", Project: "/home/u/p"}
	rule := "file write ${UV_CACHE_DIR:-${XDG_CACHE_HOME:-~/.cache}/uv}/\n"
	for _, c := range []struct {
		uv, xdg, want string
	}{
		{"", "", "/home/u/.cache/uv"},
		{"", "/home/u/.xdg-cache", "/home/u/.xdg-cache/uv"},
		{"/srv/uv", "/home/u/.xdg-cache", "/srv/uv"},
		{"relative/uv", "", "/home/u/.cache/uv"},
		{"/home/u", "", "/home/u/.cache/uv"}, // home itself
		{"/", "", "/home/u/.cache/uv"},       // contains home
		{"/a:/b", "", "/home/u/.cache/uv"},   // a list
		{"/srv/*", "", "/home/u/.cache/uv"},  // a pattern
	} {
		t.Setenv("UV_CACHE_DIR", c.uv)
		t.Setenv("XDG_CACHE_HOME", c.xdg)
		comp, err := compileIn(t, env, Floor{}, rule)
		if err != nil {
			t.Fatal(err)
		}
		if !comp.NamedDir(c.want) || !comp.Access(c.want+"/x").Write {
			t.Errorf("UV_CACHE_DIR=%q XDG_CACHE_HOME=%q: not %s: %+v", c.uv, c.xdg, c.want, comp.rules)
		}
	}
	for _, bad := range []string{
		"${A}/x", "${A:-~/x", "${A:-}/x", "${1A:-~/x}", "${A:-~/x}y", "${A:-~/*}", "${A:-$B}", "x/${A:-~/x}", "${A:-~/x}/${B:-~/y}",
	} {
		if err := ValidatePath(bad); err == nil {
			t.Errorf("%q accepted", bad)
		}
	}
	t.Setenv("XDG_CONFIG_HOME", "")
	f, err := Parse([]byte("env set CLAUDE_CONFIG_DIR ${XDG_CONFIG_HOME:-~/.config}/claude\n"))
	if err != nil {
		t.Fatal(err)
	}
	c, err := Compile(Layer{"policy", f}, Floor{}, env)
	if err != nil || !slices.Equal(c.EnvValues(), []string{"CLAUDE_CONFIG_DIR=/home/u/.config/claude"}) {
		t.Errorf("env set: %q, %v", c.EnvValues(), err)
	}
}

// A variable's directory counts only as the kernel will see it: not one
// that leads, through a symlink, to / or home, nor one in the project,
// where a rule for it would undo the policy's hide rules.
func TestVarPathLinks(t *testing.T) {
	root := realTemp(t)
	env := Env{Home: filepath.Join(root, "home"), Project: filepath.Join(root, "home", "proj")}
	if err := os.MkdirAll(filepath.Join(env.Project, ".env"), 0o755); err != nil {
		t.Fatal(err)
	}
	for name, target := range map[string]string{"to-root": "/", "to-home": env.Home} {
		if err := os.Symlink(target, filepath.Join(root, name)); err != nil {
			t.Fatal(err)
		}
	}
	text := "file write .\nfile hide **/.env*\nfile read ${XDG_CONFIG_HOME:-~/.config}\n"
	for _, v := range []string{filepath.Join(root, "to-root"), filepath.Join(root, "to-home"), filepath.Join(env.Project, ".env")} {
		t.Setenv("XDG_CONFIG_HOME", v)
		c, err := compileIn(t, env, Floor{}, text)
		if err != nil {
			t.Fatal(err)
		}
		for _, p := range []string{"/etc/hostname", filepath.Join(env.Home, "notes"), filepath.Join(env.Project, ".env", "x")} {
			if c.Access(p).Read {
				t.Errorf("XDG_CONFIG_HOME=%s: %s is readable", v, p)
			}
		}
	}
}

// The floor follows the XDG base directories wherever they are.
func TestFloorFollowsXDG(t *testing.T) {
	env := Env{Home: "/home/u", Project: "/home/u/p"}
	t.Setenv("XDG_CONFIG_HOME", "/home/u/dotfiles/config")
	c, err := compileIn(t, env, DefaultFloor(), "file write ${XDG_CONFIG_HOME:-~/.config}\n")
	if err != nil {
		t.Fatal(err)
	}
	if a := c.Access("/home/u/dotfiles/config/gh/hosts.yml"); a.Read || !a.Floor {
		t.Errorf("the relocated gh token: %+v", a)
	}
	if a := c.Access("/home/u/dotfiles/config/systemd/user/x.service"); a.Write {
		t.Errorf("the relocated systemd units: %+v", a)
	}
	if !c.Access("/home/u/dotfiles/config/claude/x").Write {
		t.Error("a program's own configuration is not writable")
	}
}