Download
1526 lines 55.8 KiBGoModified 2026-10-31 raw
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526
package sandbox

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net"
	"net/http"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"testing"
	"time"

	"blinker/internal/policy"
	"blinker/internal/pty"
	"blinker/internal/testenv"
)

// The test binary stands in for blinker inside the sandbox.
func TestMain(m *testing.M) {
	if code, ok := RunHidden(os.Args); ok {
		os.Exit(code)
	}
	if err := testenv.LeaveHome(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	os.Exit(m.Run())
}

// Bash runs a script with bash in the sandbox, collecting its output.
func (s *Sandbox) Bash(ctx context.Context, mode policy.Mode, script string, timeout time.Duration, out io.Writer) (Result, error) {
	return s.Run(ctx, Cmd{
		Mode:    mode,
		Argv:    []string{s.shell, "-c", script},
		Stdin:   bytes.NewReader(nil),
		Stdout:  out,
		Stderr:  out,
		Timeout: timeout,
		Network: true,
	})
}

type fixture struct {
	sb      *Sandbox
	home    string
	project string
}

// setup makes a home and a project under a temp dir, writes the policy for
// the project, and starts a sandbox there.
func setup(t *testing.T, pol *testPolicy, opts Options) *fixture {
	t.Helper()
	switch runtime.GOOS {
	case "linux":
		if _, err := exec.LookPath("bwrap"); err != nil {
			t.Skip("bwrap not installed")
		}
		if err := exec.Command("bwrap", "--ro-bind", "/", "/", "true").Run(); err != nil {
			t.Skip("bwrap cannot run here: ", err)
		}
	}
	// Outside the system's temp dir, where commands see the real one (see
	// TestProjectInTmp).
	base, err := os.MkdirTemp(".", ".sandbox-test-")
	if err != nil {
		t.Fatal(err)
	}
	return setupIn(t, base, pol, opts)
}

// setupIn is setup with home and the project in base, which it removes
// after.
func setupIn(t *testing.T, base string, pol *testPolicy, opts Options) *fixture {
	t.Helper()
	t.Cleanup(func() { os.RemoveAll(base) })
	base, _ = filepath.Abs(base)
	base, _ = filepath.EvalSymlinks(base)
	home := filepath.Join(base, "home")
	project := filepath.Join(home, "proj")
	for _, d := range []string{project, filepath.Join(home, ".ssh"), filepath.Join(home, "other"), filepath.Join(home, ".config", "blinker")} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	files := map[string]string{
		"proj/main.go":           "package main\n",
		"proj/.env":              "SECRET=1\n",
		"proj/sub/.env.dev":      "SECRET=2\n",
		"proj/key.pem":           "PRIVATE\n",
		".ssh/id_ed25519":        "PRIVATE\n",
		"other/notes.txt":        "notes\n",
		".bashrc":                "# rc\n",
		".config/blinker/config": "api-key sk-1\n",
	}
	for name, content := range files {
		p := filepath.Join(home, name)
		os.MkdirAll(filepath.Dir(p), 0o755)
		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	t.Setenv("HOME", home)
	t.Chdir(project)
	configDir := filepath.Join(home, ".config", "blinker")
	if err := savePolicy(configDir, project, pol); err != nil {
		t.Fatal(err)
	}
	opts.ConfigDir = configDir
	if opts.ProxyPorts == nil {
		opts.ProxyPorts = []int{443, 80}
	}
	sb, err := New(opts)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(sb.Close)
	return &fixture{sb: sb, home: home, project: project}
}

// renew replaces the fixture's sandbox with a new one, which sees the
// files as they are now.
func (f *fixture) renew(t *testing.T) {
	t.Helper()
	sb, err := New(Options{ConfigDir: filepath.Join(f.home, ".config", "blinker"), ProxyPorts: []int{443, 80}})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(sb.Close)
	f.sb = sb
}

func (f *fixture) bash(t *testing.T, mode policy.Mode, script string) (string, Result) {
	t.Helper()
	var out bytes.Buffer
	res, err := f.sb.Bash(context.Background(), mode, script, 30*time.Second, &out)
	if err != nil {
		t.Fatalf("%s: %v", script, err)
	}
	return out.String(), res
}

// testPolicy is a test's two policies in one: each file rule gives an
// access in each mode, the network and env rules apply in both, and
// UnsafeNet and UnsafeEnv in unsafe mode only, after the others. Only
// these rules apply, unless IncludeAgent has the policies start from the
// agent's built-in ones, as a new project's do.
type testPolicy struct {
	Files        []testRule
	Network      []policy.NetRule
	Env          []policy.EnvRule
	UnsafeNet    []policy.NetRule
	UnsafeEnv    []policy.EnvRule
	IncludeAgent bool
}

type testRule struct {
	Path         string
	Safe, Unsafe policy.Perm
}

func rule(path string, safe, unsafe policy.Perm) testRule {
	return testRule{Path: path, Safe: safe, Unsafe: unsafe}
}

// savePolicy writes a test's policies as a project's.
func savePolicy(configDir, project string, p *testPolicy) error {
	dir := policy.ProjectDir(configDir, project)
	for _, m := range []policy.Mode{policy.SafeMode, policy.UnsafeMode} {
		f := &policy.File{Directory: project, Network: p.Network, Env: p.Env}
		if p.IncludeAgent {
			f.Includes = []policy.Include{{Name: policy.AgentPolicy(m)}}
		}
		for _, r := range p.Files {
			access := r.Safe
			if m == policy.UnsafeMode {
				access = r.Unsafe
			}
			f.Files = append(f.Files, policy.FileRule{Path: r.Path, Access: access})
		}
		if m == policy.UnsafeMode {
			f.Network = append(slices.Clone(f.Network), p.UnsafeNet...)
			f.Env = append(slices.Clone(f.Env), p.UnsafeEnv...)
		}
		if err := policy.Save(filepath.Join(dir, m.String()+".policy"), f); err != nil {
			return err
		}
	}
	return nil
}

// basicPolicy gives home, then hides ~/other, makes the project readable
// without asking and writable once approved, and hides .env files.
func basicPolicy() *testPolicy {
	return &testPolicy{Files: []testRule{
		rule("~", policy.Write, policy.Write),
		rule("~/other", policy.None, policy.None),
		rule("**", policy.Read, policy.Write),
		rule("**/.env*", policy.None, policy.None),
	}}
}

func TestFileAccess(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	cases := []struct {
		mode   policy.Mode
		script string
		ok     bool
	}{
		{policy.SafeMode, "cat main.go", true},
		{policy.SafeMode, "echo x > new.txt", false},
		{policy.UnsafeMode, "echo x > new.txt", true},
		{policy.SafeMode, "cat .env", false},
		{policy.UnsafeMode, "cat .env", false},
		{policy.UnsafeMode, "cat sub/.env.dev", false},
		{policy.UnsafeMode, "cat key.pem", false},                  // floor glob
		{policy.UnsafeMode, "cat ~/.ssh/id_ed25519", false},        // floor
		{policy.UnsafeMode, "echo x >> ~/.bashrc", false},          // never-write floor
		{policy.SafeMode, "cat ~/.bashrc", true},                   // readable though
		{policy.UnsafeMode, "cat ~/other/notes.txt", false},        // forbid rule
		{policy.UnsafeMode, "cat ~/.config/blinker/config", false}, // blinker's config
		{policy.SafeMode, `echo x > "$TMPDIR/f" && cat "$TMPDIR/f"`, true},
		{policy.SafeMode, "ls /usr/bin >/dev/null", true},
	}
	for _, tc := range cases {
		out, res := f.bash(t, tc.mode, tc.script)
		if (res.ExitCode == 0) != tc.ok {
			t.Errorf("%s %q: exit %d, want ok=%v; output: %s", tc.mode, tc.script, res.ExitCode, tc.ok, out)
		}
	}
	if _, err := os.Stat(filepath.Join(f.home, "proj", "new.txt")); err != nil {
		t.Error("unsafe write did not reach the project:", err)
	}
}

// A rule for an ancestor of home mounts over the home dir's own mount; the
// floor inside must still be hidden.
func TestRuleAboveHome(t *testing.T) {
	f := setup(t, &testPolicy{}, Options{})
	pol := &testPolicy{Files: []testRule{rule(filepath.Dir(f.home), policy.Write, policy.Write)}}
	if err := savePolicy(filepath.Join(f.home, ".config", "blinker"), f.project, pol); err != nil {
		t.Fatal(err)
	}
	if err := f.sb.Reload(); err != nil {
		t.Fatal(err)
	}
	if out, res := f.bash(t, policy.SafeMode, "cat main.go"); res.ExitCode != 0 {
		t.Errorf("the granted project is unreadable: %s", out)
	}
	if out, res := f.bash(t, policy.SafeMode, "cat ~/.ssh/id_ed25519"); res.ExitCode == 0 {
		t.Errorf("read the floor: %s", out)
	}
}

func TestSymlinkCannotWidenGrant(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("**", policy.Read, policy.Read),
		rule("build", policy.Write, policy.Write),
	}}, Options{})
	// The agent replaces build with a link to somewhere the policy does not
	// grant; the grant must not follow it.
	if err := os.Symlink(filepath.Join(f.home, "other"), filepath.Join(f.project, "build")); err != nil {
		t.Fatal(err)
	}
	out, res := f.bash(t, policy.SafeMode, "echo x > build/pwned")
	if res.ExitCode == 0 {
		t.Errorf("wrote through a symlinked grant: %s", out)
	}
	if _, err := os.Stat(filepath.Join(f.home, "other", "pwned")); err == nil {
		t.Error("file created outside the policy")
	}
}

func TestReadWriteFile(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	ctx := context.Background()
	data, err := f.sb.ReadFile(ctx, policy.SafeMode, filepath.Join(f.project, "main.go"), 1<<20)
	if err != nil || string(data) != "package main\n" {
		t.Fatalf("ReadFile = %q, %v", data, err)
	}
	if _, err := f.sb.ReadFile(ctx, policy.SafeMode, filepath.Join(f.project, "main.go"), 5); !errors.Is(err, ErrTooLarge) {
		t.Errorf("ReadFile over its limit: %v", err)
	}
	if _, err := f.sb.ReadFile(ctx, policy.UnsafeMode, filepath.Join(f.project, ".env"), 1<<20); err == nil {
		t.Error("read .env")
	}
	target := filepath.Join(f.project, "dir", "new.go")
	if err := f.sb.WriteFile(ctx, policy.SafeMode, target, []byte("x")); err == nil {
		t.Error("safe-mode write succeeded")
	}
	if err := f.sb.WriteFile(ctx, policy.UnsafeMode, target, []byte("hello")); err != nil {
		t.Fatal(err)
	}
	if got, _ := os.ReadFile(target); string(got) != "hello" {
		t.Errorf("wrote %q", got)
	}
}

// The session's temp dir lasts from command to command, whatever the
// rules say, and is all a command sees of the system's temp dirs.
func TestTempDir(t *testing.T) {
	outside, err := os.CreateTemp("", "blinker-test-")
	if err != nil {
		t.Fatal(err)
	}
	outside.Close()
	t.Cleanup(func() { os.Remove(outside.Name()) })
	f := setup(t, &testPolicy{Files: []testRule{
		rule("/", policy.Read, policy.Read), // writing only the test's home, not the user's
		rule("~", policy.Write, policy.Write),
		rule(systemTemp(t), policy.None, policy.None), // not even a rule reopens it
	}}, Options{})
	tmp := f.sb.TempDir()
	for _, mode := range []policy.Mode{policy.SafeMode, policy.UnsafeMode} {
		if out, res := f.bash(t, mode, `echo `+mode.String()+` > "$TMPDIR/`+mode.String()+`"`); res.ExitCode != 0 {
			t.Fatalf("%s: writing the temp dir: %s", mode, out)
		}
		if out, _ := f.bash(t, mode, `cat "$TMPDIR/`+mode.String()+`"`); out != mode.String()+"\n" {
			t.Errorf("%s: the temp dir did not last: %q", mode, out)
		}
		if out, res := f.bash(t, mode, "cat "+outside.Name()); res.ExitCode == 0 {
			t.Errorf("%s: read the system's temp dir: %q", mode, out)
		}
		if out, res := f.bash(t, mode, "ls "+filepath.Dir(outside.Name())+" | grep -q blinker-test-"); res.ExitCode == 0 {
			t.Errorf("%s: listed the system's temp dir: %q", mode, out)
		}
	}
	if runtime.GOOS == "linux" {
		if out, _ := f.bash(t, policy.SafeMode, "touch /tmp/x && echo $TMPDIR"); out != "/tmp\n" {
			t.Errorf("TMPDIR is %q", out)
		}
	}

	// The file tools, given the path commands see.
	ctx := context.Background()
	host := f.sb.HostPath(filepath.Join(tmp, "dir", "f"))
	if pol, _ := f.sb.Policy(); !pol.Safe.Access(host).Write {
		t.Errorf("%s is not writable in safe mode", host)
	}
	if err := f.sb.WriteFile(ctx, policy.SafeMode, host, []byte("hi")); err != nil {
		t.Fatal(err)
	}
	if data, err := f.sb.ReadFile(ctx, policy.SafeMode, host, 100); err != nil || string(data) != "hi" {
		t.Errorf("ReadFile = %q, %v", data, err)
	}
	if out, _ := f.bash(t, policy.SafeMode, `cat "$TMPDIR/dir/f"`); out != "hi" {
		t.Errorf("a command sees %q", out)
	}

	session := f.sb.session
	f.sb.Close()
	if _, err := os.Stat(session); !errors.Is(err, os.ErrNotExist) {
		t.Errorf("the session's dir is still there: %v", err)
	}
}

// systemTemp is the system's temp dir, as policies see it.
func systemTemp(t *testing.T) string {
	t.Helper()
	dir, err := filepath.EvalSymlinks(os.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	return dir
}

// A session that ended without cleaning up leaves its dir behind, which a
// session a week later removes.
func TestCleanupStale(t *testing.T) {
	root := t.TempDir()
	stale, recent := filepath.Join(root, sessionPrefix+"stale"), filepath.Join(root, sessionPrefix+"recent")
	for _, d := range []string{stale, recent} {
		if err := os.MkdirAll(filepath.Join(d, "tmp"), 0o700); err != nil {
			t.Fatal(err)
		}
	}
	old := time.Now().Add(-8 * 24 * time.Hour)
	if err := os.Chtimes(stale, old, old); err != nil {
		t.Fatal(err)
	}
	(&Sandbox{}).cleanupStale(root)
	if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
		t.Errorf("the stale session's dir is still there: %v", err)
	}
	if _, err := os.Stat(recent); err != nil {
		t.Errorf("the recent session's dir is gone: %v", err)
	}
}

func TestEnvironment(t *testing.T) {
	t.Setenv("MY_API_KEY", "sk-secret")
	t.Setenv("SSH_AUTH_SOCK", "/tmp/agent")
	t.Setenv("BLINKER_PLAIN", "1")
	t.Setenv("PLAIN_VAR", "plain")
	t.Setenv("UNSAFE_ONLY", "approved")
	t.Setenv("LANG", "C.UTF-8")
	t.Setenv("PAGER", "less")
	pol := basicPolicy()
	pol.Env = []policy.EnvRule{
		{Name: "SSH_*", Access: policy.Allow}, // the floor wins
		{Name: "PAGER", Access: policy.Allow}, // blinker's own wins
		{Name: "UNSAFE_ONLY", Access: policy.Deny},
	}
	pol.UnsafeEnv = []policy.EnvRule{{Name: "UNSAFE_ONLY", Access: policy.Allow}}
	f := setup(t, pol, Options{})
	out, _ := f.bash(t, policy.SafeMode, "env")
	for _, bad := range []string{"sk-secret", "SSH_AUTH_SOCK", "BLINKER_PLAIN", "PLAIN_VAR", "UNSAFE_ONLY"} {
		if strings.Contains(out, bad) {
			t.Errorf("safe mode gets %s", bad)
		}
	}
	if strings.Contains(out, "PAGER=less") || strings.Count("\n"+out, "\nPAGER=") != 1 {
		t.Errorf("PAGER is not just blinker's:\n%s", out)
	}
	for _, good := range []string{"TMPDIR=" + f.sb.TempDir() + "\n", "LANG=C.UTF-8", "BLINKER_MODE=safe", "AGENT=blinker", "AI_AGENT=blinker", "NO_COLOR=1", "GIT_TERMINAL_PROMPT=0"} {
		if !strings.Contains(out, good) {
			t.Errorf("safe mode lacks %s", good)
		}
	}
	if out, _ = f.bash(t, policy.UnsafeMode, "env"); !strings.Contains(out, "UNSAFE_ONLY=approved") || strings.Contains(out, "SSH_AUTH_SOCK") {
		t.Errorf("unsafe mode env:\n%s", out)
	}
}

func TestModelEnv(t *testing.T) {
	for model, want := range map[string]bool{
		"claude-opus-5-5":          true,
		"meta-llama/Llama-3.3-70B": true,
		"qwen2.5-coder:32b":        true,
		"":                         false,
		"two words":                false,
		"x\nCo-authored-by: evil":  false,
		"\x1b[31mred":              false,
		"modèle":                   false,
		strings.Repeat("m", 201):   false,
	} {
		if got := modelEnv(model); (len(got) == 1 && got[0] == "AI_MODEL="+model) != want || (!want && got != nil) {
			t.Errorf("modelEnv(%q) = %q, want set %v", model, got, want)
		}
	}
}

func TestModelReachesCommands(t *testing.T) {
	t.Setenv("AI_MODEL", "users-own")
	pol := basicPolicy()
	pol.Env = []policy.EnvRule{{Name: "AI_MODEL", Access: policy.Allow}}
	f := setup(t, pol, Options{})
	var out bytes.Buffer
	if _, err := f.sb.Run(context.Background(), Cmd{Mode: policy.SafeMode, Argv: []string{f.sb.Shell(), "-c", "env"}, Stdout: &out, Stderr: &out, Model: "test-model"}); err != nil {
		t.Fatal(err)
	}
	if env := "\n" + out.String(); !strings.Contains(env, "\nAI_MODEL=test-model\n") || strings.Count(env, "\nAI_MODEL=") != 1 {
		t.Errorf("AI_MODEL is not just the model's:\n%s", out.String())
	}
}

// A command cannot read blinker's own environment through another
// process: ps on macOS, /proc on Linux.
func TestParentEnvironmentHidden(t *testing.T) {
	t.Setenv("BLINKER_TEST_MARKER", "parent-env-marker")
	f := setup(t, basicPolicy(), Options{})
	out, _ := f.bash(t, policy.UnsafeMode, "ps eww -o command= -p $PPID 2>&1; ps eww -A -o command= 2>&1; cat /proc/$PPID/environ 2>&1; true")
	if strings.Contains(out, "parent-env-marker") {
		t.Errorf("blinker's environment is visible:\n%s", out)
	}
}

// Commands get the harmless devices, but no terminal and little of /sys.
func TestDevices(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	for script, ok := range map[string]bool{
		"head -c 4 /dev/zero | wc -c && echo x > /dev/null": true,
		"head -c 4 /dev/urandom | wc -c":                    true,
		"echo x > /dev/tty":                                 false,
		"ls /dev/ttys000 /dev/disk0 /dev/sda /dev/nvme0n1":  false,
	} {
		if out, res := f.bash(t, policy.UnsafeMode, script); (res.ExitCode == 0) != ok {
			t.Errorf("%s: exit %d, want success %v: %s", script, res.ExitCode, ok, out)
		}
	}
	switch runtime.GOOS {
	case "linux":
		if out, res := f.bash(t, policy.SafeMode, "ls /sys/devices/system/cpu | grep -q cpu0 && ! ls /sys/class /sys/devices/virtual"); res.ExitCode != 0 {
			t.Errorf("/sys: %s", out)
		}
	}
}

// A "$PATH" rule makes the directories in $PATH readable, but not home
// itself when $PATH names it.
func TestEnvPathRule(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("**", policy.Read, policy.Write),
		rule("$PATH", policy.Read, policy.Read),
	}}, Options{})
	bin := filepath.Join(f.home, "tools", "bin")
	if err := os.MkdirAll(bin, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(bin, "hello-blinker"), []byte("#!/bin/sh\necho hi from bin\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", bin+":"+f.home+":.::"+os.Getenv("PATH"))
	if err := f.sb.Reload(); err != nil {
		t.Fatal(err)
	}
	if out, res := f.bash(t, policy.SafeMode, "hello-blinker"); res.ExitCode != 0 || !strings.Contains(out, "hi from bin") {
		t.Errorf("running from $PATH: %d %s", res.ExitCode, out)
	}
	if out, res := f.bash(t, policy.UnsafeMode, "cat ~/other/notes.txt"); res.ExitCode == 0 {
		t.Errorf("home was readable through $PATH: %s", out)
	}
	if _, res := f.bash(t, policy.UnsafeMode, "echo x > "+bin+"/new"); res.ExitCode == 0 {
		t.Error("wrote into a $PATH directory")
	}
}

func TestTimeout(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	var out bytes.Buffer
	start := time.Now()
	res, err := f.sb.Bash(context.Background(), policy.SafeMode, "sleep 30", 300*time.Millisecond, &out)
	if err != nil || !res.TimedOut {
		t.Fatalf("res = %+v, err = %v", res, err)
	}
	if time.Since(start) > 10*time.Second {
		t.Error("timeout took too long")
	}
}

// upstream starts a local HTTP server and returns its port.
func upstream(t *testing.T) int {
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "hello from upstream")
	})}
	go srv.Serve(l)
	t.Cleanup(func() { srv.Close() })
	return l.Addr().(*net.TCPAddr).Port
}

func TestNetwork(t *testing.T) {
	if _, err := exec.LookPath("curl"); err != nil {
		t.Skip("curl not installed")
	}
	port := upstream(t)
	asked := 0
	pol := basicPolicy()
	// As the agent's policies have it: ask, which safe mode takes as deny.
	pol.Network = []policy.NetRule{{Host: "*", Access: policy.Ask}, {Host: "localhost", Access: policy.Allow}}
	f := setup(t, pol, Options{
		AllowPrivate: true,
		ProxyPorts:   []int{port},
		AskDomain: func(ctx context.Context, host string) DomainAnswer {
			asked++
			if host == "127.0.0.1" {
				return DomainAllowOnce
			}
			return DomainDeny
		},
	})
	url := fmt.Sprintf("http://localhost:%d/", port)
	out, res := f.bash(t, policy.SafeMode, "curl -sS --max-time 5 "+url)
	if res.ExitCode != 0 || !strings.Contains(out, "hello from upstream") {
		t.Fatalf("safe mode, allowed domain: exit %d: %s", res.ExitCode, out)
	}
	// Bypassing the proxy finds no network at all.
	out, res = f.bash(t, policy.SafeMode, "curl -sS --noproxy '*' --max-time 5 "+url)
	if res.ExitCode == 0 {
		t.Errorf("direct connection worked: %s", out)
	}
	// A domain no rule allows: refused in safe mode, without asking, and
	// blamed on that command only, not on one running beside it.
	beside := make(chan Result)
	go func() {
		res, _ := f.sb.Bash(context.Background(), policy.SafeMode, "sleep 2", 30*time.Second, io.Discard)
		beside <- res
	}()
	time.Sleep(500 * time.Millisecond)
	out, res = f.bash(t, policy.SafeMode, fmt.Sprintf("curl -sS -f --max-time 5 http://127.0.0.1:%d/", port))
	if res.ExitCode == 0 || asked != 0 || len(res.DeniedHosts) != 1 {
		t.Errorf("safe mode reached an unlisted domain or asked: exit %d, asked %d, denied %v: %s", res.ExitCode, asked, res.DeniedHosts, out)
	}
	if res := <-beside; len(res.DeniedHosts) != 0 {
		t.Errorf("a command was told of another's refusal: %v", res.DeniedHosts)
	}
	// In unsafe mode the user is asked, once per command.
	out, res = f.bash(t, policy.UnsafeMode, fmt.Sprintf("curl -sS --max-time 5 http://127.0.0.1:%d/ http://127.0.0.1:%d/", port, port))
	if res.ExitCode != 0 || asked != 1 {
		t.Errorf("unsafe mode with approval: exit %d, asked %d: %s", res.ExitCode, asked, out)
	}
	// Ports not on the list are refused.
	f.sb.opts.ProxyPorts = []int{port}
	out, res = f.bash(t, policy.SafeMode, "curl -sS -f --max-time 5 http://localhost:1/")
	if res.ExitCode == 0 {
		t.Errorf("reached a port not allowed: %s", out)
	}
}

// An answer for the session holds for later unsafe commands, without a
// rule; one for always adds a rule to the unsafe policy. Neither reaches
// safe mode.
func TestRememberDomain(t *testing.T) {
	if _, err := exec.LookPath("curl"); err != nil {
		t.Skip("curl not installed")
	}
	port := upstream(t)
	for _, answer := range []DomainAnswer{DomainAllowSession, DomainAllowAlways} {
		asked := 0
		pol := basicPolicy()
		pol.UnsafeNet = []policy.NetRule{{Host: "*", Access: policy.Ask}}
		f := setup(t, pol, Options{
			AllowPrivate: true,
			ProxyPorts:   []int{port},
			AskDomain: func(context.Context, string) DomainAnswer {
				asked++
				return answer
			},
		})
		url := fmt.Sprintf("http://localhost:%d/", port)
		for range 2 {
			if out, res := f.bash(t, policy.UnsafeMode, "curl -sS --max-time 5 "+url); res.ExitCode != 0 {
				t.Fatalf("%v: unsafe: %s", answer, out)
			}
		}
		if asked != 1 {
			t.Errorf("%v: asked %d times", answer, asked)
		}
		if out, res := f.bash(t, policy.SafeMode, "curl -sS -f --max-time 5 "+url); res.ExitCode == 0 {
			t.Errorf("%v: safe mode reached it: %s", answer, out)
		}
		data, err := os.ReadFile(filepath.Join(policy.ProjectDir(filepath.Join(f.home, ".config", "blinker"), f.project), "unsafe.policy"))
		if err != nil {
			t.Fatal(err)
		}
		if saved := strings.Contains(string(data), "host    allow   localhost"); saved != (answer == DomainAllowAlways) {
			t.Errorf("%v: the rule saved: %v\n%s", answer, saved, data)
		}
	}
}

// Under blinker's named policy, a "host ask" rule asks in unsafe mode,
// once for the command, and the command learns why it was refused.
func TestNamedPolicyAsks(t *testing.T) {
	if _, err := exec.LookPath("curl"); err != nil {
		t.Skip("curl not installed")
	}
	port := upstream(t)
	named, err := policy.Parse([]byte("file write .\nhost ask *\n"))
	if err != nil {
		t.Fatal(err)
	}
	asked := 0
	f := setup(t, &testPolicy{}, Options{
		Named:        &policy.Layer{Name: "policy test", File: named},
		AllowPrivate: true,
		ProxyPorts:   []int{port},
		AskDomain: func(_ context.Context, host string) DomainAnswer {
			asked++
			if host == "localhost" {
				return DomainAllowOnce
			}
			return DomainDeny
		},
	})
	url := fmt.Sprintf("http://localhost:%d/", port)
	if out, res := f.bash(t, policy.UnsafeMode, "curl -sS --max-time 5 "+url+" "+url); res.ExitCode != 0 || asked != 1 || !strings.Contains(out, "hello from upstream") {
		t.Errorf("allowed once: exit %d, asked %d: %s", res.ExitCode, asked, out)
	}
	out, res := f.bash(t, policy.UnsafeMode, fmt.Sprintf("curl -sS --max-time 5 http://127.0.0.1:%d/", port))
	if !strings.Contains(out, "the policy does not let the command connect") || len(res.DeniedHosts) != 1 {
		t.Errorf("denied: exit %d, denied %v: %s", res.ExitCode, res.DeniedHosts, out)
	}
}

func TestGlobExpansionFailsClosed(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	for i := range 50 {
		if err := os.WriteFile(filepath.Join(f.project, fmt.Sprintf("f%d", i)), nil, 0o644); err != nil {
			t.Fatal(err)
		}
	}
	defer func(n int) { walkLimit = n }(walkLimit)
	walkLimit = 20
	var out bytes.Buffer
	_, err := f.sb.Bash(context.Background(), policy.SafeMode, "cat .env", 10*time.Second, &out)
	if err == nil || !strings.Contains(err.Error(), "too many to apply") {
		t.Fatalf("ran with globs half applied: err = %v, output %q", err, out.String())
	}
}

func TestPatternGrantsWrite(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("**", policy.Read, policy.Read),
		rule("**/*.md", policy.Write, policy.Write),
	}}, Options{})
	if err := os.WriteFile(filepath.Join(f.project, "README.md"), []byte("# hi\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	// Creating a file needs its directory writable, which would allow any
	// name, and globs match only what exists when the command starts: new
	// files come only through the file tools.
	cases := []struct {
		script string
		ok     bool
	}{
		{"echo more >> README.md", true},
		{"echo x >> main.go", false},
		{"cat main.go", true},
		{"echo x > new.md", false},
	}
	for _, tc := range cases {
		out, res := f.bash(t, policy.SafeMode, tc.script)
		if (res.ExitCode == 0) != tc.ok {
			t.Errorf("%q: exit %d, want ok=%v: %s", tc.script, res.ExitCode, tc.ok, out)
		}
	}
	if got, _ := os.ReadFile(filepath.Join(f.project, "README.md")); string(got) != "# hi\nmore\n" {
		t.Errorf("README.md = %q", got)
	}
}

func TestWriteFileCreatesWherePatternAllows(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("**", policy.Read, policy.Read),
		rule("**/*.md", policy.Write, policy.Write),
	}}, Options{})
	ctx := context.Background()
	if err := f.sb.WriteFile(ctx, policy.SafeMode, filepath.Join(f.project, "docs", "new.md"), []byte("new\n")); err != nil {
		t.Fatalf("creating an allowed file: %v", err)
	}
	if got, _ := os.ReadFile(filepath.Join(f.project, "docs", "new.md")); string(got) != "new\n" {
		t.Errorf("new.md = %q", got)
	}
	if err := f.sb.WriteFile(ctx, policy.SafeMode, filepath.Join(f.project, "new.go"), []byte("x")); err == nil {
		t.Error("created a file the policy does not allow")
	}
	if _, err := os.Stat(filepath.Join(f.project, "new.go")); err == nil {
		t.Error("new.go exists")
	}
}

// A protected path that does not exist yet cannot be created through a
// writable directory, and the mount points that keep it so are gone from
// the host afterwards.
func TestMissingProtectedPathsStayMissing(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~", policy.Write, policy.Write),
		rule(".git/hooks", policy.Read, policy.Read),
		rule(".envrc", policy.Read, policy.Write),
		rule("secret", policy.None, policy.None),
		rule("deep/er/secret", policy.None, policy.None),
	}}, Options{})
	if err := os.Mkdir(filepath.Join(f.project, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Remove(filepath.Join(f.home, ".bashrc")); err != nil {
		t.Fatal(err)
	}
	missing := []string{
		filepath.Join(f.project, ".git", "hooks"),
		filepath.Join(f.project, ".envrc"),
		filepath.Join(f.project, "secret"),
		filepath.Join(f.project, "deep"),
		filepath.Join(f.home, ".bashrc"), // never writable, by the floor
		filepath.Join(f.home, ".cargo"),  // holds ~/.cargo/bin, never writable
	}
	for _, s := range []string{
		"mkdir -p .git/hooks",
		"echo x > .envrc",
		"echo x > secret",
		"mkdir -p deep/er/secret",
		// Made by renaming a directory over the empty one above it.
		`mkdir -p d2/secret && perl -e 'rename "d2", "deep/er" or die "$!\n"'`,
		"echo x > ~/.bashrc",
		"mkdir -p ~/.cargo/bin",
	} {
		if out, res := f.bash(t, policy.SafeMode, s); res.ExitCode == 0 {
			t.Errorf("%q succeeded: %s", s, out)
		}
	}
	// A blocked file reads as empty, as programs expect of a missing
	// optional file. Seatbelt leaves it missing.
	out, res := f.bash(t, policy.SafeMode, "cat ~/.bashrc")
	switch runtime.GOOS {
	case "linux":
		if res.ExitCode != 0 || out != "" {
			t.Errorf("reading a blocked file: exit %d: %s", res.ExitCode, out)
		}
	case "darwin":
		if res.ExitCode == 0 {
			t.Errorf("read a blocked file: %q", out)
		}
	}
	// Unsafe mode may write .envrc, so it gets no blocker there.
	if out, res := f.bash(t, policy.UnsafeMode, "echo x > .envrc && rm .envrc"); res.ExitCode != 0 {
		t.Errorf("unsafe mode could not create .envrc: %s", out)
	}
	if out, res := f.bash(t, policy.SafeMode, "echo x > new.txt && mkdir -p deep/other"); res.ExitCode != 0 {
		t.Errorf("writing beside them failed: %s", out)
	}
	for _, p := range missing {
		if p == filepath.Join(f.project, "deep") {
			continue // made by the command above, for deep/other
		}
		if _, err := os.Lstat(p); err == nil {
			t.Errorf("%s is left on the host", p)
		}
	}
}

// Removing a mount point on the host unmounts it in a sandbox still using
// it, so another command ending, which removes those it made, must leave
// it until the sandboxes are idle.
func TestMountPointsOutliveTheirUsers(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	profile := filepath.Join(f.home, ".profile")
	done := make(chan string)
	go func() {
		var out bytes.Buffer
		f.sb.Bash(context.Background(), policy.UnsafeMode, "sleep 1; echo x >> ~/.profile && echo wrote", 30*time.Second, &out)
		done <- out.String()
	}()
	time.Sleep(500 * time.Millisecond)
	if _, err := os.Lstat(profile); err != nil {
		t.Fatalf("no mount point for ~/.profile: %v", err)
	}
	f.sb.removeMountPoints([]string{profile}) // as a command that made it too would
	if out := <-done; strings.Contains(out, "wrote") {
		t.Errorf("wrote ~/.profile once its mount point was removed: %s", out)
	}
	if _, err := os.Lstat(profile); err == nil {
		t.Error("the mount point is left on the host once the sandbox is idle")
	}
}

// A mount follows its directory when renamed, but the next command's
// mounts follow names: moving a protected path's directory aside must
// fail, or the path would lose its protection.
func TestProtectedPathsCannotBeMoved(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~", policy.Write, policy.Write),
		rule("vendor/lib", policy.Read, policy.Read),
		rule("sub/secret", policy.None, policy.None),
	}}, Options{})
	for name, content := range map[string]string{"vendor/lib/a": "orig\n", "sub/secret/x": "S\n"} {
		p := filepath.Join(f.project, name)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.MkdirAll(filepath.Join(f.home, ".local", "bin"), 0o755); err != nil {
		t.Fatal(err)
	}
	for _, s := range []string{"mv vendor v2", "mv sub sub2", "mv ~/.local ~/l2", "rm -rf vendor"} {
		if out, res := f.bash(t, policy.SafeMode, s); res.ExitCode == 0 {
			t.Errorf("%q succeeded: %s", s, out)
		}
	}
	if out, res := f.bash(t, policy.SafeMode, "cat sub2/secret/x; echo x > v2/lib/a; echo x > ~/l2/bin/ls"); !strings.Contains(out, "No such file") {
		t.Errorf("exit %d: %s", res.ExitCode, out)
	}
	if got, err := os.ReadFile(filepath.Join(f.project, "vendor/lib/a")); err != nil || string(got) != "orig\n" {
		t.Errorf("vendor/lib/a = %q, %v", got, err)
	}
}

// WriteFile writes the path the policy judged, never through a symlink.
func TestWriteFileRefusesSymlinks(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	if err := os.Symlink(filepath.Join(f.home, "other"), filepath.Join(f.project, "link")); err != nil {
		t.Fatal(err)
	}
	for _, p := range []string{"link/new.txt", "link/notes.txt"} {
		if err := f.sb.WriteFile(context.Background(), policy.UnsafeMode, filepath.Join(f.project, p), []byte("x")); err == nil {
			t.Errorf("wrote %s", p)
		}
	}
	if got, _ := os.ReadFile(filepath.Join(f.home, "other", "notes.txt")); string(got) != "notes\n" {
		t.Errorf("notes.txt = %q", got)
	}
	if _, err := os.Lstat(filepath.Join(f.home, "other", "new.txt")); err == nil {
		t.Error("created a file through the link")
	}
}

// A scratch root of one's own holds every session's proxy sockets, so no
// rule opens it; and it cannot hold the project.
func TestScratchRoot(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{rule("~", policy.Write, policy.Write)}}, Options{DataDirs: []string{"~/.local/share/blinker"}})
	root := filepath.Join(f.home, "scratch")
	if err := os.Mkdir(root, 0o700); err != nil {
		t.Fatal(err)
	}
	sb, err := New(Options{ConfigDir: filepath.Join(f.home, ".config", "blinker"), ScratchRoot: root})
	if err != nil {
		t.Fatal(err)
	}
	defer sb.Close()
	var out bytes.Buffer
	// Hidden, it shows as an empty directory under bubblewrap; Seatbelt
	// refuses to list it.
	res, err := sb.Bash(context.Background(), policy.SafeMode, "ls -A "+root, 30*time.Second, &out)
	switch runtime.GOOS {
	case "linux":
		if err != nil || out.String() != "" {
			t.Errorf("the scratch root shows: %v %q", err, out.String())
		}
	case "darwin":
		if err != nil || res.ExitCode == 0 {
			t.Errorf("listed the scratch root: %v %q", err, out.String())
		}
	}
	out.Reset()
	if res, err := sb.Bash(context.Background(), policy.SafeMode, `echo x > "$TMPDIR/f" && cat "$TMPDIR/f"`, 30*time.Second, &out); err != nil || res.ExitCode != 0 {
		t.Errorf("the session's own temp dir: %v %s", err, out.String())
	}
	if _, err := New(Options{ConfigDir: filepath.Join(f.home, ".config", "blinker"), ScratchRoot: f.home}); err == nil {
		t.Error("a scratch root holding the project was accepted")
	}
}

// In a project under /tmp, the real /tmp is shown as the policy says, as
// any other dir is. The session keeps a temp dir of its own, the proxy
// still works, and other sessions' dirs stay hidden even from a rule for
// "/".
func TestProjectInTmp(t *testing.T) {
	tmpProject := func(t *testing.T, pol *testPolicy, opts Options) (*fixture, string) {
		base, err := os.MkdirTemp("/tmp", "blinker-test-")
		if err != nil {
			t.Fatal(err)
		}
		f := setupIn(t, base, pol, opts)
		if !policy.Under(f.project, "/tmp") && !policy.Under(f.project, "/private/tmp") {
			t.Fatalf("the project %s is not under /tmp", f.project)
		}
		sensitive := filepath.Join(filepath.Dir(f.home), "sensitive")
		if err := os.WriteFile(sensitive, []byte("secret\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		return f, sensitive
	}

	t.Run("home only", func(t *testing.T) {
		pol := &testPolicy{Files: []testRule{rule("~", policy.Write, policy.Write)}}
		f, sensitive := tmpProject(t, pol, Options{})
		if out, res := f.bash(t, policy.SafeMode, `echo hi > new.txt && cat new.txt`); res.ExitCode != 0 || out != "hi\n" {
			t.Errorf("writing in the project: exit %d: %s", res.ExitCode, out)
		}
		if out, res := f.bash(t, policy.SafeMode, "cat "+sensitive); res.ExitCode == 0 {
			t.Errorf("read a file in /tmp outside the project: %s", out)
		}
		if out, res := f.bash(t, policy.SafeMode, `echo x > "$TMPDIR/f" && cat "$TMPDIR/f"`); res.ExitCode != 0 || out != "x\n" {
			t.Errorf("the session's own temp dir: exit %d: %s", res.ExitCode, out)
		}
	})

	t.Run("everything readable", func(t *testing.T) {
		port := upstream(t)
		pol := &testPolicy{
			Files:   []testRule{rule("/", policy.Read, policy.Read), rule("~", policy.Write, policy.Write)},
			Network: []policy.NetRule{{Host: "localhost", Access: policy.Allow}},
		}
		f, sensitive := tmpProject(t, pol, Options{AllowPrivate: true, ProxyPorts: []int{port}})
		if out, res := f.bash(t, policy.SafeMode, "cat "+sensitive); res.ExitCode != 0 || out != "secret\n" {
			t.Errorf("a rule for / does not show /tmp: exit %d: %s", res.ExitCode, out)
		}
		// Commands see their scratch dir where it is, so under bubblewrap
		// the root shows the path to it and to the proxy, and nothing else.
		root := filepath.Dir(f.sb.session)
		other := filepath.Join(root, sessionPrefix+"other")
		if err := os.MkdirAll(filepath.Join(other, "net"), 0o700); err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { os.RemoveAll(other) })
		out, res := f.bash(t, policy.SafeMode, "ls -A "+root+" "+f.sb.session)
		switch runtime.GOOS {
		case "linux":
			want := fmt.Sprintf("%s:\n%s\n\n%s:\nnet\ntmp\n", root, filepath.Base(f.sb.session), f.sb.session)
			if res.ExitCode != 0 || out != want {
				t.Errorf("the scratch root shows: exit %d: %q", res.ExitCode, out)
			}
		case "darwin":
			if res.ExitCode == 0 {
				t.Errorf("listed the scratch root: %q", out)
			}
		}
		if _, err := exec.LookPath("curl"); err == nil {
			url := fmt.Sprintf("http://localhost:%d/", port)
			if out, res := f.bash(t, policy.SafeMode, "curl -sS --max-time 5 "+url); res.ExitCode != 0 || !strings.Contains(out, "hello from upstream") {
				t.Errorf("the proxy: exit %d: %s", res.ExitCode, out)
			}
		}
	})
}

// Run from home, blinker can read and write nothing there, whatever the
// rules say, but still has its temp dir. Nor does it walk home for the
// default policy's globs, which would take too long.
func TestProjectIsHome(t *testing.T) {
	f := setup(t, &testPolicy{}, Options{})
	t.Chdir(f.home)
	configDir := filepath.Join(f.home, ".config", "blinker")
	defer func(n int) { walkLimit = n }(walkLimit)
	walkLimit = 5
	pol := &testPolicy{Files: []testRule{rule(".", policy.Write, policy.Write)}, IncludeAgent: true}
	if err := savePolicy(configDir, f.home, pol); err != nil {
		t.Fatal(err)
	}
	sb, err := New(Options{ConfigDir: configDir})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(sb.Close)
	f.sb = sb
	notes := filepath.Join(f.home, "other", "notes.txt")
	for _, mode := range []policy.Mode{policy.SafeMode, policy.UnsafeMode} {
		for _, script := range []string{"cat " + notes, "echo x > " + f.home + "/new", "test -n \"$(ls -A " + f.home + ")\""} {
			if out, res := f.bash(t, mode, script); res.ExitCode == 0 {
				t.Errorf("%s mode reached home: %s: %s", mode, script, out)
			}
		}
		if _, err := sb.ReadFile(context.Background(), mode, notes, 1024); err == nil {
			t.Errorf("%s mode read a file in home", mode)
		}
	}
	if _, err := os.Lstat(filepath.Join(f.home, "new")); err == nil {
		t.Error("wrote into home")
	}
	if out, res := f.bash(t, policy.SafeMode, `echo x > "$TMPDIR/f" && cat "$TMPDIR/f"`); res.ExitCode != 0 || out != "x\n" {
		t.Errorf("the session's own temp dir: exit %d: %s", res.ExitCode, out)
	}
}

// Run from /tmp itself, blinker reaches nothing of the real one, whatever
// the rules say: commands get their own temp dir as usual. That holds with
// the scratch root in it, and with the scratch root being it, as macOS's
// is when run from the user's temp dir.
func TestProjectIsTmp(t *testing.T) {
	f := setup(t, &testPolicy{}, Options{})
	tmp, err := filepath.EvalSymlinks("/tmp")
	if err != nil {
		t.Fatal(err)
	}
	base, err := os.MkdirTemp(tmp, "blinker-test-")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { os.RemoveAll(base) })
	secret := filepath.Join(base, "secret")
	if err := os.WriteFile(secret, []byte("secret\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	t.Chdir(tmp)
	configDir := filepath.Join(f.home, ".config", "blinker")
	pol := &testPolicy{Files: []testRule{rule(".", policy.Write, policy.Write)}}
	if err := savePolicy(configDir, tmp, pol); err != nil {
		t.Fatal(err)
	}
	for _, root := range []string{"", tmp} {
		t.Run("root "+root, func(t *testing.T) {
			sb, err := New(Options{ConfigDir: configDir, ScratchRoot: root})
			if err != nil {
				t.Fatal(err)
			}
			t.Cleanup(sb.Close)
			f.sb = sb
			checkHiddenTmp(t, f, tmp, base)
		})
	}
}

func checkHiddenTmp(t *testing.T, f *fixture, tmp, base string) {
	sb := f.sb
	secret := filepath.Join(base, "secret")
	if !sb.Hidden() {
		t.Error("the project is not hidden")
	}
	for _, mode := range []policy.Mode{policy.SafeMode, policy.UnsafeMode} {
		for _, script := range []string{"cat " + secret, "echo x > " + base + "/new"} {
			if out, res := f.bash(t, mode, script); res.ExitCode == 0 {
				t.Errorf("%s mode reached the real %s: %s: %s", mode, tmp, script, out)
			}
		}
		if _, err := sb.ReadFile(context.Background(), mode, secret, 1024); err == nil {
			t.Errorf("%s mode read a file in %s", mode, tmp)
		}
	}
	if _, err := os.Lstat(filepath.Join(base, "new")); err == nil {
		t.Errorf("wrote into %s", tmp)
	}
	if out, res := f.bash(t, policy.SafeMode, `echo x > "$TMPDIR/f" && cat "$TMPDIR/f"`); res.ExitCode != 0 || out != "x\n" {
		t.Errorf("the session's own temp dir: exit %d: %s", res.ExitCode, out)
	}
}

func TestPathDirs(t *testing.T) {
	root, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	bin, link := filepath.Join(root, "bin"), filepath.Join(root, "link")
	if err := os.Mkdir(bin, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(bin, link); err != nil {
		t.Fatal(err)
	}
	project := filepath.Join(root, "proj")
	got := pathDirs(strings.Join([]string{link, "", "rel", project + "/bin", root, "/opt/[x]/bin", "/usr/bin/"}, ":"), project)
	if want := []string{link, bin, "/usr/bin"}; !slices.Equal(got, want) {
		t.Errorf("pathDirs = %q, want %q", got, want)
	}
}

// In a worktree or submodule .git is a file: a rule for a path inside it
// protects nothing that can exist, and must not stop commands running.
func TestRuleUnderAFile(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~", policy.Write, policy.Write),
		rule(".git/hooks", policy.Read, policy.Read),
	}}, Options{})
	if err := os.WriteFile(filepath.Join(f.project, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if out, res := f.bash(t, policy.SafeMode, "cat .git"); res.ExitCode != 0 || out != "gitdir: /elsewhere\n" {
		t.Errorf("exit %d: %s", res.ExitCode, out)
	}
}

// The floor's key patterns are applied in a readable tree inside the
// project even when the project itself is not readable.
func TestFloorPatternsInReadableSubtree(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{rule(".git", policy.Read, policy.Read)}}, Options{})
	key := filepath.Join(f.project, ".git", "deploy.pem")
	if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(key, []byte("PRIVATE\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	if out, res := f.bash(t, policy.SafeMode, "cat "+key); res.ExitCode == 0 || strings.Contains(out, "PRIVATE") {
		t.Errorf("read the key: %s", out)
	}
}

// A repository's configuration and hooks run when the user next runs git,
// outside the sandbox, so no command writes them, even where it may write
// the rest of .git.
func TestGitFloor(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	for _, name := range []string{".git/config", ".git/HEAD", ".git/modules/sub/config", ".git/modules/sub/hooks/README", "vendor/x/.git/config"} {
		p := filepath.Join(f.project, name)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte("[core]\n"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	f.renew(t)
	for _, script := range []string{
		"echo x >> .git/config",
		"mkdir .git/hooks",
		"echo x > .git/config.worktree",
		"echo x >> .git/modules/sub/config",
		"echo x > .git/modules/sub/hooks/pre-commit",
		"echo x >> vendor/x/.git/config",
		"mv .git .git2",
	} {
		if out, res := f.bash(t, policy.UnsafeMode, script); res.ExitCode == 0 {
			t.Errorf("%q succeeded: %s", script, out)
		}
	}
	if out, res := f.bash(t, policy.UnsafeMode, "echo ref: refs/heads/x > .git/HEAD && mkdir .git/refs"); res.ExitCode != 0 {
		t.Errorf("the rest of .git is not writable: %s", out)
	}
	for _, name := range []string{".git/config", "vendor/x/.git/config"} {
		if data, _ := os.ReadFile(filepath.Join(f.project, name)); string(data) != "[core]\n" {
			t.Errorf("%s was written: %q", name, data)
		}
	}
	for _, name := range []string{".git/hooks", ".git/config.worktree", ".git/modules/sub/hooks/pre-commit"} {
		if _, err := os.Lstat(filepath.Join(f.project, name)); err == nil {
			t.Errorf("%s is left behind", name)
		}
	}
}

// A worktree's .git file says where its repository is, so a command
// pointing it elsewhere would choose the configuration git uses.
func TestGitFileFloor(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	if err := os.WriteFile(filepath.Join(f.project, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	f.renew(t)
	if out, res := f.bash(t, policy.UnsafeMode, "echo gitdir: evil > .git"); res.ExitCode == 0 {
		t.Errorf("rewrote .git: %s", out)
	}
}

// System services run outside the sandbox, so a command reaching one could
// do what the profile denies it.
func TestServicesStayOut(t *testing.T) {
	if runtime.GOOS != "darwin" {
		t.Skip("macOS services")
	}
	f := setup(t, basicPolicy(), Options{})
	hidden := filepath.Join(f.home, "other")

	// LaunchServices would start a bundle the command wrote, unsandboxed.
	app := filepath.Join(f.project, "P.app", "Contents")
	if err := os.MkdirAll(filepath.Join(app, "MacOS"), 0o755); err != nil {
		t.Fatal(err)
	}
	plist := `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>p</string>
<key>CFBundleIdentifier</key><string>blinker.test.escape</string>
<key>LSBackgroundOnly</key><true/>
</dict></plist>
`
	if err := os.WriteFile(filepath.Join(app, "Info.plist"), []byte(plist), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(app, "MacOS", "p"), []byte("#!/bin/sh\ntouch "+hidden+"/opened\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	f.bash(t, policy.UnsafeMode, "open -g -j P.app; sleep 3")
	time.Sleep(time.Second)
	if _, err := os.Stat(filepath.Join(hidden, "opened")); err == nil {
		t.Error("open started a program outside the sandbox")
	}

	// cfprefsd writes the real ~/Library/Preferences, where an app's
	// settings can run commands later.
	const domain = "blinker.test.escape"
	t.Cleanup(func() {
		exec.Command("defaults", "delete", domain).Run()
		// defaults leaves the file; cfprefsd uses the real home, not $HOME.
		if u, err := user.Current(); err == nil {
			os.Remove(filepath.Join(u.HomeDir, "Library", "Preferences", domain+".plist"))
		}
	})
	if out, res := f.bash(t, policy.UnsafeMode, "defaults write "+domain+" k v"); res.ExitCode == 0 {
		t.Errorf("defaults wrote a domain: %s", out)
	}
	if out, err := exec.Command("defaults", "read", domain).CombinedOutput(); err == nil {
		t.Errorf("the domain is there: %s", out)
	}

	sleeper := exec.Command("/bin/sleep", "60")
	if err := sleeper.Start(); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { sleeper.Process.Kill(); sleeper.Wait() })
	if out, res := f.bash(t, policy.UnsafeMode, fmt.Sprintf("kill -0 %d", sleeper.Process.Pid)); res.ExitCode == 0 {
		t.Errorf("signalled a process outside the sandbox: %s", out)
	}
}

// A policy replaces the global and project policies, and its commands are
// not told an agent runs them.
func TestNamedPolicy(t *testing.T) {
	t.Setenv("PAGER", "less")
	named, err := policy.Parse([]byte("file write .\nfile hide **/.env*\nenv allow PAGER\n"))
	if err != nil {
		t.Fatal(err)
	}
	// The project policy would let commands read home; the policy does not.
	f := setup(t, basicPolicy(), Options{Named: &policy.Layer{Name: "policy test", File: named}})
	out, _ := f.bash(t, policy.SafeMode, "touch new && echo wrote; cat .env; cat ~/.bashrc; env")
	// Installed policies are on the floor, like blinker's config, even
	// where a rule opens all of home.
	g := setup(t, &testPolicy{Files: []testRule{rule("~", policy.Write, policy.Write)}}, Options{DataDirs: []string{"~/.local/share/blinker"}})
	data := filepath.Join(g.home, ".local", "share", "blinker")
	if err := os.MkdirAll(filepath.Join(data, "policies"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(data, "policies", "safe.policy"), []byte("file read .\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if out, _ := g.bash(t, policy.UnsafeMode, "cat "+data+"/policies/safe.policy; echo x > "+data+"/policies/new.policy"); strings.Contains(out, "file read") {
		t.Errorf("a command reads the installed policies:\n%s", out)
	}
	if _, err := os.Stat(filepath.Join(data, "policies", "new.policy")); err == nil {
		t.Error("a command wrote a policy")
	}
	if !strings.Contains(out, "wrote") {
		t.Errorf("could not write the project:\n%s", out)
	}
	for _, bad := range []string{"SECRET", "# rc", "AGENT=", "BLINKER_MODE=", "NO_COLOR="} {
		if strings.Contains(out, bad) {
			t.Errorf("policy command gets %q:\n%s", bad, out)
		}
	}
	if !strings.Contains(out, "PAGER=less\n") || !strings.Contains(out, "TMPDIR="+f.sb.TempDir()+"\n") {
		t.Errorf("policy command env:\n%s", out)
	}
}

// Other users' homes are hidden, whatever the rules say: run as root,
// nothing else would keep a command out of them. One holding the project
// gets only its credentials and startup files hidden, as this home does.
func TestOtherUsersHomes(t *testing.T) {
	base, err := os.MkdirTemp(".", ".sandbox-test-")
	if err != nil {
		t.Fatal(err)
	}
	abs, _ := filepath.Abs(base)
	abs, _ = filepath.EvalSymlinks(abs)
	// bob's home is beside this one; carol's holds it, and the project.
	bob, carol := filepath.Join(abs, "bob"), abs
	for _, home := range []string{bob, carol} {
		for name, content := range map[string]string{".ssh/id_rsa": "PRIVATE\n", ".bashrc": "# rc\n", "notes.txt": "ORDINARY\n"} {
			p := filepath.Join(home, name)
			if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
				t.Fatal(err)
			}
			if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
				t.Fatal(err)
			}
		}
	}
	passwd := filepath.Join(abs, "passwd")
	if err := os.WriteFile(passwd, []byte("bob:x:1001:1001::"+bob+":/bin/sh\ncarol:x:1002:1002::"+carol+":/bin/sh\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	old := passwdFile
	passwdFile = passwd
	t.Cleanup(func() { passwdFile = old })
	f := setupIn(t, base, &testPolicy{Files: []testRule{rule(abs, policy.Write, policy.Write)}}, Options{})

	out, _ := f.bash(t, policy.UnsafeMode, "cat "+bob+"/notes.txt; ls "+bob+"; echo y > "+bob+"/new.txt && echo made")
	if strings.Contains(out, "ORDINARY") || strings.Contains(out, "id_rsa") || strings.Contains(out, "made\n") {
		t.Errorf("bob's home is open:\n%s", out)
	}
	if _, err := os.Stat(filepath.Join(bob, "new.txt")); err == nil {
		t.Error("a command wrote in bob's home")
	}

	out, _ = f.bash(t, policy.UnsafeMode, "cd "+carol+"; cat notes.txt; cat .ssh/id_rsa; echo x >> .bashrc; echo y > new.txt && echo made")
	if !strings.Contains(out, "ORDINARY") || !strings.Contains(out, "made\n") {
		t.Errorf("carol's ordinary files are not open:\n%s", out)
	}
	if strings.Contains(out, "PRIVATE") {
		t.Errorf("carol's key is readable:\n%s", out)
	}
	if data, _ := os.ReadFile(filepath.Join(carol, ".bashrc")); string(data) != "# rc\n" {
		t.Errorf("carol's .bashrc was written: %q", data)
	}

	// Run from another user's home, as from this one, nothing is open.
	t.Chdir(bob)
	sb, err := New(Options{ConfigDir: filepath.Join(f.home, ".config", "blinker")})
	if err != nil {
		t.Fatal(err)
	}
	defer sb.Close()
	if !sb.Hidden() {
		t.Error("run from bob's home, the project is not hidden")
	}
}

// A command with a pty of its own has it as its controlling terminal: it
// sees a terminal, of the pty's size, /dev/tty reaches it, and TERM names
// blinker's emulator.
func TestTerminal(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	master, slave, err := pty.Open()
	if err != nil {
		t.Skip("no pseudo-terminals here: ", err)
	}
	defer master.Close()
	if err := pty.SetSize(master, 77, 19); err != nil {
		t.Fatal(err)
	}
	var out bytes.Buffer
	read := make(chan struct{})
	go func() {
		io.Copy(&out, master) // until EIO, once the command is gone
		close(read)
	}()
	res, err := f.sb.Run(context.Background(), Cmd{
		Mode:     policy.SafeMode,
		Argv:     []string{f.sb.Shell(), "-c", `test -t 0 && test -t 1 && echo on-a-tty; stty size; echo via-dev-tty > /dev/tty; echo "TERM=$TERM"; tty`},
		Terminal: slave,
		Timeout:  30 * time.Second,
	})
	if err != nil || res.ExitCode != 0 {
		t.Fatalf("%+v, %v", res, err)
	}
	select {
	case <-read:
	case <-time.After(5 * time.Second):
		t.Fatal("the pty did not end with the command")
	}
	for _, want := range []string{"on-a-tty", "19 77", "via-dev-tty", "TERM=xterm-256color"} {
		if !strings.Contains(out.String(), want) {
			t.Errorf("no %q in:\n%s", want, out.String())
		}
	}
	t.Logf("tty says: %q", out.String())

	// Never blinker's own terminal, and never with other stdio.
	if _, err := f.sb.Run(context.Background(), Cmd{Mode: policy.SafeMode, Argv: []string{"true"}, Terminal: os.Stdin}); err == nil {
		t.Error("ran a command on blinker's own stdin as its terminal")
	}
	_, slave2, err := pty.Open()
	if err != nil {
		t.Fatal(err)
	}
	if _, err := f.sb.Run(context.Background(), Cmd{Mode: policy.SafeMode, Argv: []string{"true"}, Terminal: slave2, Stdin: strings.NewReader("")}); err == nil {
		t.Error("ran a command with a terminal and a stdin")
	}
}

// A command reaches a file through the user's link in home, as it would
// outside, though home is otherwise hidden.
func TestHomeSymlinkShown(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~/.vimrc", policy.Read, policy.Read),
		rule("~/.config/vim", policy.Read, policy.Read),
	}}, Options{})
	dots := filepath.Join(f.home, "other", "dotfiles")
	if err := os.MkdirAll(filepath.Join(dots, "vim"), 0o755); err != nil {
		t.Fatal(err)
	}
	for name, content := range map[string]string{"vimrc": "set nu\n", "vim/init.vim": "set et\n", "secret": "x\n"} {
		if err := os.WriteFile(filepath.Join(dots, name), []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.Symlink("other/dotfiles/vimrc", filepath.Join(f.home, ".vimrc")); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(filepath.Join(dots, "vim"), filepath.Join(f.home, ".config", "vim")); err != nil {
		t.Fatal(err)
	}
	if err := f.sb.Reload(); err != nil { // to follow the new links
		t.Fatal(err)
	}
	if out, res := f.bash(t, policy.SafeMode, "cat ~/.vimrc ~/.config/vim/init.vim"); res.ExitCode != 0 || out != "set nu\nset et\n" {
		t.Errorf("through the links: %q, %+v", out, res)
	}
	if out, res := f.bash(t, policy.SafeMode, "cat ~/other/dotfiles/secret"); res.ExitCode == 0 {
		t.Errorf("read beside the link's target: %s", out)
	}
	if out, res := f.bash(t, policy.SafeMode, "echo x >> ~/.vimrc"); res.ExitCode == 0 {
		t.Errorf("wrote through a read-only link: %s", out)
	}
}

// A missing directory a rule names with a trailing "/" is made if the
// mode may write it, but never in the project, and blocked as an empty
// directory, not a file, if it may not be written, so a program that
// lists it finds nothing.
func TestDirRules(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~/.cache/tool/", policy.Write, policy.Write),
		rule("~/other", policy.Write, policy.Write),
		rule("~/other/rules/", policy.Read, policy.Read),
		rule("build/", policy.Write, policy.Write),
	}}, Options{})
	out, res := f.bash(t, policy.SafeMode, "touch ~/.cache/tool/x && test -d ~/other/rules && ls ~/other/rules && ! touch ~/other/rules/x 2>/dev/null && ! test -e build")
	if res.ExitCode != 0 {
		t.Errorf("%q, %+v", out, res)
	}
	if _, err := os.Stat(filepath.Join(f.home, ".cache", "tool", "x")); err != nil {
		t.Errorf("the made directory: %v", err)
	}
	if _, err := os.Lstat(filepath.Join(f.home, "other", "rules")); !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("the block is left behind: %v", err)
	}
	if _, err := os.Lstat(filepath.Join(f.project, "build")); !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("made a directory in the project: %v", err)
	}
}

// A policy's "env set" gives a command the variable, "~/" as home, but
// never in place of one of blinker's own.
func TestNamedEnvSet(t *testing.T) {
	t.Setenv("CLAUDE_CONFIG_DIR", "/elsewhere")
	named, err := policy.Parse([]byte("file write .\nenv set CLAUDE_CONFIG_DIR ~/.config/claude\nenv set TMPDIR /nope\n"))
	if err != nil {
		t.Fatal(err)
	}
	f := setup(t, &testPolicy{}, Options{Named: &policy.Layer{Name: "policy test", File: named}})
	out, _ := f.bash(t, policy.SafeMode, "echo $CLAUDE_CONFIG_DIR; echo $TMPDIR")
	if want := filepath.Join(f.home, ".config", "claude") + "\n"; !strings.HasPrefix(out, want) || strings.Contains(out, "/nope") {
		t.Errorf("got %q, want %q first, and blinker's TMPDIR", out, want)
	}
}

// A policy passes the user's API key to the agent it runs, when blinker
// says so; a policy never gives the agent's key to the agent's commands.
func TestAgentKeyInNamedOnly(t *testing.T) {
	t.Setenv("OPENAI_API_KEY", "sk-test")
	named, err := policy.Parse([]byte("file write .\nenv allow OPENAI_API_KEY\n"))
	if err != nil {
		t.Fatal(err)
	}
	f := setup(t, &testPolicy{}, Options{Named: &policy.Layer{Name: "policy test", File: named}})
	if out, _ := f.bash(t, policy.SafeMode, "echo key=$OPENAI_API_KEY"); out != "key=sk-test\n" {
		t.Errorf("policy: %q", out)
	}
	g := setup(t, &testPolicy{Env: []policy.EnvRule{{Name: "OPENAI_API_KEY", Access: policy.Allow}}}, Options{})
	if out, _ := g.bash(t, policy.UnsafeMode, "echo key=$OPENAI_API_KEY"); out != "key=\n" {
		t.Errorf("agent policy: %q", out)
	}
}