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