package main import ( "context" "errors" "fmt" "net" "os" "path/filepath" "reflect" "regexp" "strings" "testing" "github.com/containers/podman/v5/pkg/specgen" spec "github.com/opencontainers/runtime-spec/specs-go" "go.podman.io/common/libnetwork/types" ) func writeList(t *testing.T, lines ...string) string { t.Helper() f := filepath.Join(t.TempDir(), "hosts") if err := os.WriteFile(f, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { t.Fatal(err) } return f } func lines(s string, re string) []string { var out []string for _, l := range strings.Split(s, "\n") { if regexp.MustCompile(re).MatchString(l) { out = append(out, l) } } return out } func check[T any](t *testing.T, name string, want, got T) { t.Helper() if !reflect.DeepEqual(want, got) { t.Errorf("%s:\nwant %#v\ngot %#v", name, want, got) } } func TestRouterConfig(t *testing.T) { f := writeList(t, "# comment", "", " API.Anthropic.com # trailing", ".example.org", "git.example.net:22", "db:5432", "db", "web:443", "web:80", "git.example.net:5432") entries, err := parseAllowlist(f) if err != nil { t.Fatal(err) } check(t, "entries", []entry{{".example.org", ""}, {"api.anthropic.com", ""}, {"db", ""}, {"db", "5432"}, {"git.example.net", "22"}, {"git.example.net", "5432"}, {"web", ""}}, entries) dnsmasq, nginx, forwards := routerConfig(entries, "10.89.7.2") check(t, "forwards", "0.0.0.0:443:10.89.7.2:443 0.0.0.0:80:10.89.7.2:80 127.1.0.2:5432:10.89.7.2:10000 "+ "127.1.0.3:22:10.89.7.2:10001 127.1.0.3:5432:10.89.7.2:10002", forwards) check(t, "dnsmasq", "no-resolv\nno-hosts\nbind-interfaces\nlisten-address=10.89.7.2\naddress=/#/\n"+ "address=/example.org/127.0.0.2\nhost-record=api.anthropic.com,127.1.0.1\nhost-record=db,127.1.0.2\n"+ "host-record=git.example.net,127.1.0.3\nhost-record=web,127.1.0.4\n", dnsmasq) check(t, "dnsmasq-never-forwards", []string(nil), lines(dnsmasq, `^(server|resolv-file)=`)) tlsMap := nginx[strings.Index(nginx, "map $ssl_preread_server_name"):] httpMap := nginx[strings.Index(nginx, "map $host"):] want := []string{" .example.org 1;", " api.anthropic.com 1;", " db 1;", " git.example.net 1;", " web 1;"} check(t, "tls-map", want, lines(tlsMap[:strings.Index(tlsMap, "}")], ` 1;$`)) check(t, "http-map", want, lines(httpMap[:strings.Index(httpMap, "}")], ` 1;$`)) check(t, "tls-default-deny", []string{" default 127.0.0.1:9;"}, lines(nginx, `default 127`)) check(t, "http-default-deny", []string{" if ($http_allowed = 0) { return 403; }"}, lines(nginx, `return 403`)) check(t, "tcp-servers", []string{ " server { listen 10.89.7.2:10000; set $up db:5432; proxy_pass $up; }", " server { listen 10.89.7.2:10001; set $up git.example.net:22; proxy_pass $up; }", " server { listen 10.89.7.2:10002; set $up git.example.net:5432; proxy_pass $up; }", }, lines(nginx, `set \$up`)) check(t, "listens-on-router-only", len(lines(nginx, `listen `)), len(lines(nginx, `listen 10\.89\.7\.2:`))) check(t, "resolver-placeholder", 2, strings.Count(nginx, "resolver @RESOLVER@ ipv6=off valid=30s;")) } func TestRouterConfigOverlap(t *testing.T) { entries, err := parseAllowlist(writeList(t, ".example.org", "example.org", "example.org:22", "a.example.org", "example.org")) if err != nil { t.Fatal(err) } dnsmasq, nginx, _ := routerConfig(entries, "10.89.7.2") check(t, "overlap-dnsmasq", []string{"address=/example.org/127.0.0.2", "host-record=a.example.org,127.1.0.1", "host-record=example.org,127.1.0.2"}, lines(dnsmasq, `^(address=/e|host-record)`)) check(t, "overlap-map", []string{" .example.org 1;", " a.example.org 1;", " .example.org 1;", " a.example.org 1;"}, lines(nginx, `^ .* 1;$`)) } func TestParseAllowlistRejects(t *testing.T) { for _, bad := range []string{"exa mple.com", "example.com:0", "example.com:70000", "example.com:ssh", "*.example.com", "-bad.com", "http://example.com", "a..b", ".example.com:22", "example.com:+22", "example.com:-1"} { if _, err := parseAllowlist(writeList(t, bad)); err == nil { t.Errorf("accepted %q", bad) } } _, err := parseAllowlist(writeList(t, "ok.example.com", "bad host", "z.example.com")) check(t, "bad-line-error", true, err != nil && strings.HasSuffix(err.Error(), ":2: one entry per line")) } func TestDefaultAllowlistParses(t *testing.T) { if _, err := parseAllowlist("allowed-hosts"); err != nil { t.Fatal(err) } } func TestRealpath(t *testing.T) { d, _ := filepath.EvalSymlinks(t.TempDir()) _ = os.MkdirAll(d+"/a/b", 0o755) _ = os.Symlink(d+"/a/b", d+"/abs") _ = os.Symlink("a/b", d+"/rel") _ = os.Symlink(d+"/missing/x", d+"/dangling") _ = os.Symlink(d+"/loop", d+"/loop") for in, want := range map[string]string{ d + "/abs": d + "/a/b", d + "/rel/c/d": d + "/a/b/c/d", d + "/rel/..": d + "/a", d + "/dangling/y": d + "/missing/x/y", d + "/a/./b/": d + "/a/b", "/": "/", d + "/loop/z": d + "/loop/z", d + "/nope/../a/b/..": d + "/a", } { check(t, in, want, realpath(in)) } } type fakeEngine struct { calls []string specs map[string]*specgen.SpecGenerator pod *specgen.PodSpecGenerator nets map[string]types.Network apiDir string execCode int run func(ctx context.Context) (int, error) } func (f *fakeEngine) call(c string) { f.calls = append(f.calls, c) } func (f *fakeEngine) NetworkCreate(_ context.Context, n *types.Network, ignore bool) (types.Network, error) { f.call("network create " + n.Name) f.nets[n.Name] = *n sub, _ := types.ParseCIDR("10.89.7.0/24") return types.Network{Name: n.Name, Subnets: []types.Subnet{{Subnet: sub}}}, nil } func (f *fakeEngine) NetworkRemove(_ context.Context, name string) error { f.call("network rm " + name) return nil } func (f *fakeEngine) VolumeCreate(_ context.Context, name string) error { f.call("volume create " + name) return nil } func (f *fakeEngine) PodCreate(_ context.Context, s *specgen.PodSpecGenerator) error { f.call("pod create " + s.Name) f.pod = s return nil } func (f *fakeEngine) PodRemove(_ context.Context, name string) error { f.call("pod rm " + name) return nil } func (f *fakeEngine) ContainerCreate(_ context.Context, s *specgen.SpecGenerator) error { f.call("create " + s.Name) f.specs[s.Name] = s return nil } func (f *fakeEngine) ContainerStart(_ context.Context, name string) error { f.call("start " + name) return nil } func (f *fakeEngine) ContainerRun(ctx context.Context, name string) (int, error) { f.call("run " + name) if f.run != nil { return f.run(ctx) } return 0, nil } func (f *fakeEngine) ContainerExec(_ context.Context, name string, cmd []string) (int, error) { f.call("exec " + name + " " + strings.Join(cmd, " ")) return f.execCode, nil } func (f *fakeEngine) ContainerLogs(_ context.Context, name string, tail int) string { f.call("logs " + name) return "boom" } func (f *fakeEngine) ContainerRemove(_ context.Context, name string) error { f.call("rm " + name) return nil } type env struct { work string good string fake *fakeEngine } func setup(t *testing.T) *env { work, _ := filepath.EvalSymlinks(t.TempDir()) for _, d := range []string{"home/proj", "other", "rt"} { _ = os.MkdirAll(filepath.Join(work, d), 0o755) } e := &env{work: work, good: writeList(t, "api.anthropic.com")} t.Setenv("HOME", work+"/home") t.Setenv("XDG_RUNTIME_DIR", work+"/rt") t.Setenv("XDG_DATA_HOME", "") t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("AGENT_SANDBOX_ALLOWLIST", e.good) t.Setenv("AGENT_SANDBOX_IMAGE", "") t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "") t.Setenv("AGENT_SANDBOX_HOME_VOLUME", "") t.Setenv("PATH", work+"/home/bin:"+work+"/home/go/bin:/usr/bin") // t.TempDir lives under /tmp, which real configs may not use as a root. origRoots := forbiddenRoots forbiddenRoots = []string{"/run", "/var/tmp", "/proc", "/sys", "/dev"} t.Cleanup(func() { forbiddenRoots = origRoots }) orig := startService startService = func(dir string) (context.Context, engine, func(), error) { e.fake.call("service start") e.fake.apiDir = dir return context.Background(), e.fake, func() { e.fake.call("service stop") }, nil } t.Cleanup(func() { startService = orig }) e.reset() return e } func (e *env) reset() { e.fake = &fakeEngine{specs: map[string]*specgen.SpecGenerator{}, nets: map[string]types.Network{}} } func (e *env) launch(t *testing.T, dir string, args ...string) int { return e.launchCtx(t, context.Background(), dir, args...) } func (e *env) launchCtx(t *testing.T, ctx context.Context, dir string, args ...string) int { t.Chdir(dir) t.Setenv("PWD", dir) return launch(ctx, args) } func writePaths(t *testing.T, e *env, lines ...string) { t.Helper() f := allowedPathsFile() _ = os.MkdirAll(filepath.Dir(f), 0o755) if err := os.WriteFile(f, []byte("# roots\n\n"+strings.Join(lines, "\n")+"\n"), 0o644); err != nil { t.Fatal(err) } } func TestLaunchRefusals(t *testing.T) { cases := []struct { name string dir string prep func(t *testing.T, e *env) }{ {"bad-line", "home/proj", func(t *testing.T, e *env) { t.Setenv("AGENT_SANDBOX_ALLOWLIST", writeList(t, "ok.example.com", "bad host")) }}, {"home", "home", nil}, {"home-ancestor", ".", nil}, {"root", "/", nil}, {"home-symlink", "homelink", func(t *testing.T, e *env) { _ = os.Symlink(e.work+"/home", e.work+"/homelink") }}, {"config-parent", "other", func(t *testing.T, e *env) { t.Setenv("XDG_CONFIG_HOME", e.work+"/other/cfg") }}, {"allowlist-dir", "home/proj", func(t *testing.T, e *env) { t.Setenv("AGENT_SANDBOX_ALLOWLIST", e.work+"/home/proj/hosts") }}, {"runtime-dir", "other/run", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/other/run", 0o755) t.Setenv("XDG_RUNTIME_DIR", e.work+"/other/run") }}, {"runtime-parent", "other", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/other/run", 0o755) t.Setenv("XDG_RUNTIME_DIR", e.work+"/other/run") }}, {"runtime-symlink", "other", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/other/run", 0o755) _ = os.Symlink(e.work+"/other/run", e.work+"/home/runlink") t.Setenv("XDG_RUNTIME_DIR", e.work+"/home/runlink") }}, {"runtime-fallback", "/tmp", func(t *testing.T, e *env) { t.Setenv("XDG_RUNTIME_DIR", "") }}, {"colon", "home/a:b", func(t *testing.T, e *env) { _ = os.Mkdir(e.work+"/home/a:b", 0o755) }}, {"symlinked-allowlist-target", "home/dotfiles", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/dotfiles", 0o755) _ = os.MkdirAll(e.work+"/home/.config/agent-sandbox", 0o755) b, _ := os.ReadFile(e.good) _ = os.WriteFile(e.work+"/home/dotfiles/hosts", b, 0o644) _ = os.Symlink(e.work+"/home/dotfiles/hosts", e.work+"/home/.config/agent-sandbox/allowed-hosts") t.Setenv("XDG_CONFIG_HOME", e.work+"/home/.config") t.Setenv("AGENT_SANDBOX_ALLOWLIST", "") }}, {"symlinked-config-dir", "home/dotfiles", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/dotfiles", 0o755) _ = os.Symlink(e.work+"/home/dotfiles", e.work+"/home/cfglink") t.Setenv("XDG_CONFIG_HOME", e.work+"/home/cfglink") t.Setenv("AGENT_SANDBOX_ALLOWLIST", "") }}, {"runtime-under-home", "home/rt", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/rt", 0o700) t.Setenv("XDG_RUNTIME_DIR", e.work+"/home/rt") }}, {"tmp", "/tmp", nil}, {"path-entry", "home/bin", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/bin", 0o755) }}, {"contains-path-entry", "home/go", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/go/bin", 0o755) }}, {"symlink-to-path-entry", "home/binlink", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/bin", 0o755) _ = os.Symlink(e.work+"/home/bin", e.work+"/home/binlink") }}, {"relative-path-entry", "home/proj", func(t *testing.T, e *env) { t.Setenv("PATH", "/usr/bin:.") }}, {"root-contains-path-entry", "home/src/x", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/src/x", 0o755) t.Setenv("PATH", e.work+"/home/src/x/bin:/usr/bin") writePaths(t, e, "~/src") }}, {"home-config", "home/.config/x", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/.config/x", 0o755) }}, {"home-ssh", "home/.ssh", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/.ssh", 0o700) }}, {"symlink-to-runtime-dir", "home/runlink", func(t *testing.T, e *env) { _ = os.Symlink(e.work+"/rt", e.work+"/home/runlink") }}, {"outside-home", "other", nil}, {"custom-root-itself", "other", func(t *testing.T, e *env) { writePaths(t, e, e.work+"/other") }}, {"allowed-paths-dir", "home/src/p", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/src/p", 0o755) t.Setenv("XDG_CONFIG_HOME", e.work+"/home/src/p/cfg") writePaths(t, e, "~/src") }}, {"invalid-root", "home/proj", func(t *testing.T, e *env) { writePaths(t, e, "/run/x") }}, {"var-tmp-under-var-root", "/var/tmp", func(t *testing.T, e *env) { writePaths(t, e, "/var") }}, {"xdg-config-subdir", "home/cfg/systemd", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/cfg/systemd", 0o755) t.Setenv("XDG_CONFIG_HOME", e.work+"/home/cfg") }}, {"xdg-data-home", "home/data", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/data", 0o755) t.Setenv("XDG_DATA_HOME", e.work+"/home/data") }}, {"xdg-data-containers", "home/data/containers", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/data/containers", 0o755) t.Setenv("XDG_DATA_HOME", e.work+"/home/data") }}, {"default-root-dot-dir", "home/.local/src/x", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/.local/src/x", 0o755) }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := setup(t) if c.prep != nil { c.prep(t, e) } dir := c.dir if !filepath.IsAbs(dir) { dir = filepath.Join(e.work, dir) } if _, err := os.Stat(dir); err != nil { t.Fatal(err) } check(t, "exit", 1, e.launch(t, dir, "--exec", "true")) check(t, "podman calls", []string(nil), e.fake.calls) }) } } func TestCheckLaunchDirRuntimeFallback(t *testing.T) { t.Setenv("HOME", "/nonexistent/home") t.Setenv("XDG_CONFIG_HOME", "/nonexistent/cfg") t.Setenv("XDG_RUNTIME_DIR", "") t.Setenv("PATH", "/usr/bin") _, err := checkLaunchDir("/tmp", "/nonexistent/hosts") if err == nil || !strings.Contains(err.Error(), "contains "+realpath("/tmp")) { t.Fatalf("got %v", err) } t.Setenv("HOME", "/tmp/home") _, err = checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts") check(t, "forbidden default root", true, err != nil && strings.Contains(err.Error(), "overlaps "+realpath("/tmp"))) origRoots := forbiddenRoots forbiddenRoots = []string{"/run"} t.Cleanup(func() { forbiddenRoots = origRoots }) _, err = checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts") check(t, "under runtime dir", true, err != nil && strings.Contains(err.Error(), "overlaps "+realpath("/tmp"))) t.Setenv("XDG_RUNTIME_DIR", "/nonexistent/rt") _, err = checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts") check(t, "subdir", nil, err) } func leftovers(t *testing.T, dir string) { t.Helper() entries, _ := os.ReadDir(dir) for _, d := range entries { if strings.HasPrefix(d.Name(), "agent-sandbox-") { t.Errorf("leftover %s", d.Name()) } } } func TestLaunch(t *testing.T) { e := setup(t) dir := e.work + "/home/proj" var apiMode os.FileMode e.fake.run = func(context.Context) (int, error) { fi, err := os.Stat(e.fake.apiDir) if err == nil { apiMode = fi.Mode().Perm() } return 0, nil } check(t, "exit", 0, e.launch(t, dir, "--exec", "true")) f := e.fake session := strings.TrimPrefix(f.calls[3], "network create ") check(t, "session", true, regexp.MustCompile(`^agent-sandbox-\d+-\d+$`).MatchString(session)) router, fwd, claude := session+"-router", session+"-forwarder", session+"-claude" check(t, "calls", []string{ "service start", "network create agent-sandbox-egress", "volume create agent-sandbox-home", "network create " + session, "create " + router, "start " + router, "exec " + router + " nc -z 10.89.7.2 443", "pod create " + session, "create " + fwd, "start " + fwd, "exec " + fwd + " nc -z 127.0.0.1 443", "create " + claude, "run " + claude, "pod rm " + session, "rm " + router, "network rm " + session, "service stop", }, f.calls) check(t, "egress net", types.Network{Name: "agent-sandbox-egress", Driver: "bridge", DNSEnabled: true}, f.nets["agent-sandbox-egress"]) check(t, "session net", types.Network{Name: session, Driver: "bridge", Internal: true}, f.nets[session]) yes := true r := f.specs[router] tmp := r.Mounts[0].Source check(t, "tmp under runtime dir", true, strings.HasPrefix(tmp, e.work+"/rt/"+session+".")) check(t, "api dir private", os.FileMode(0o700), apiMode) check(t, "api dir not mounted", false, strings.HasPrefix(f.apiDir, tmp)) for _, d := range []string{tmp, f.apiDir} { if _, err := os.Stat(d); !os.IsNotExist(err) { t.Errorf("%s not removed", d) } } leftovers(t, e.work+"/rt") check(t, "router image", "localhost/agent-sandbox-router:dev", r.Image) check(t, "router rm", &yes, r.Remove) check(t, "router netns", specgen.Bridge, r.NetNS.NSMode) check(t, "router nets", map[string]types.PerNetworkOptions{ session: {StaticIPs: []net.IP{net.ParseIP("10.89.7.2")}}, "agent-sandbox-egress": {}, }, r.Networks) check(t, "router mounts", []spec.Mount{{Type: "bind", Source: tmp, Destination: "/etc/router", Options: []string{"ro", "Z"}}}, r.Mounts) p := f.pod check(t, "pod hostname", "agent-sandbox", p.Hostname) check(t, "pod netns", specgen.Bridge, p.NetNS.NSMode) check(t, "pod nets", map[string]types.PerNetworkOptions{session: {}}, p.Networks) check(t, "pod dns", []net.IP{net.ParseIP("10.89.7.2")}, p.DNSServer) check(t, "pod no-hosts", true, p.NoManageHosts) check(t, "pod userns", specgen.Namespace{NSMode: specgen.KeepID, Value: "uid=1000,gid=1000"}, p.Userns) fw := f.specs[fwd] check(t, "fwd pod", session, fw.Pod) check(t, "fwd user", "0:0", fw.User) check(t, "fwd ro", &yes, fw.ReadOnlyFilesystem) check(t, "fwd caps", [][]string{{"all"}, {"NET_BIND_SERVICE"}}, [][]string{fw.CapDrop, fw.CapAdd}) check(t, "fwd entrypoint", []string{"/usr/local/bin/forwarder"}, fw.Entrypoint) check(t, "fwd env", map[string]string{"FORWARDS": "0.0.0.0:443:10.89.7.2:443 0.0.0.0:80:10.89.7.2:80"}, fw.Env) check(t, "fwd rm", (*bool)(nil), fw.Remove) c := f.specs[claude] check(t, "claude image", "localhost/agent-sandbox:dev", c.Image) check(t, "claude flags", []*bool{&yes, &yes, &yes}, []*bool{c.Remove, c.Stdin, c.Init}) check(t, "claude tty", false, *c.Terminal) check(t, "claude pod", session, c.Pod) check(t, "claude cap-drop", []string{"NET_ADMIN", "NET_RAW", "SYS_ADMIN"}, c.CapDrop) check(t, "claude cap-add", []string(nil), c.CapAdd) check(t, "claude label", []string{"disable"}, c.SelinuxOpts) check(t, "claude devices", []spec.LinuxDevice{{Path: "/dev/fuse"}, {Path: "/dev/net/tun"}}, c.Devices) check(t, "claude volumes", []*specgen.NamedVolume{{Name: "agent-sandbox-home", Dest: "/home/claude"}}, c.Volumes) check(t, "claude mounts", []spec.Mount{{Type: "bind", Source: dir, Destination: dir}}, c.Mounts) check(t, "claude workdir", dir, c.WorkDir) check(t, "claude env", map[string]string{"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_TMPDIR": "/var/tmp"}, c.Env) check(t, "claude cmd", []string{"true"}, c.Command) check(t, "claude privileged", (*bool)(nil), c.Privileged) } func TestLaunchClaudeArgsAndOverrides(t *testing.T) { e := setup(t) t.Setenv("AGENT_SANDBOX_IMAGE", "img:1") t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "rimg:1") t.Setenv("AGENT_SANDBOX_HOME_VOLUME", "vol") check(t, "exit", 0, e.launch(t, e.work+"/home/proj", "--continue", "-p", "hi")) for name, s := range e.fake.specs { switch { case strings.HasSuffix(name, "-claude"): check(t, "claude cmd", []string{"claude", "--continue", "-p", "hi"}, s.Command) check(t, "claude image", "img:1", s.Image) check(t, "volume", "vol", s.Volumes[0].Name) default: check(t, name+" image", "rimg:1", s.Image) } } check(t, "volume create", "volume create vol", e.fake.calls[2]) } func TestImageNamesPinVersion(t *testing.T) { t.Setenv("AGENT_SANDBOX_IMAGE", "") t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "") defer func(v string) { version = v }(version) version = "v1.2.3" img, router := imageNames() check(t, "pinned", [2]string{registry + "/agent-sandbox:v1.2.3", registry + "/agent-sandbox-router:v1.2.3"}, [2]string{img, router}) } func TestLaunchExitCode(t *testing.T) { e := setup(t) e.fake.run = func(context.Context) (int, error) { return 7, nil } check(t, "exit", 7, e.launch(t, e.work+"/home/proj", "--exec", "false")) check(t, "cleanup last", "service stop", e.fake.calls[len(e.fake.calls)-1]) } func TestLaunchSignalCleansUp(t *testing.T) { e := setup(t) ctx, cancel := context.WithCancel(context.Background()) e.fake.run = func(opCtx context.Context) (int, error) { cancel() <-opCtx.Done() return 125, opCtx.Err() } check(t, "exit", 130, e.launchCtx(t, ctx, e.work+"/home/proj", "--exec", "sleep", "60")) n := len(e.fake.calls) check(t, "cleanup", []string{"pod rm", "rm", "network rm", "service stop"}, []string{e.fake.calls[n-4][:6], e.fake.calls[n-3][:2], e.fake.calls[n-2][:10], e.fake.calls[n-1]}) leftovers(t, e.work+"/rt") } func TestLaunchServiceFails(t *testing.T) { e := setup(t) startService = func(string) (context.Context, engine, func(), error) { return nil, nil, nil, errors.New("no podman") } check(t, "exit", 125, e.launch(t, e.work+"/home/proj", "--exec", "true")) leftovers(t, e.work+"/rt") } func TestLaunchSeedsAllowlist(t *testing.T) { e := setup(t) t.Setenv("AGENT_SANDBOX_ALLOWLIST", "") t.Setenv("XDG_CONFIG_HOME", e.work+"/cfg") check(t, "exit", 0, e.launch(t, e.work+"/home/proj", "--exec", "true")) got, err := os.ReadFile(e.work + "/cfg/agent-sandbox/allowed-hosts") if err != nil { t.Fatal(err) } check(t, "seeded", string(defaultAllowlist), string(got)) } func TestLaunchRouterNeverStarts(t *testing.T) { e := setup(t) e.fake.execCode = 1 check(t, "exit", 1, e.launch(t, e.work+"/home/proj", "--exec", "true")) for _, c := range e.fake.calls { if strings.HasPrefix(c, "pod create") { t.Error("pod created after router failed") } } check(t, "logs fetched", true, strings.Contains(strings.Join(e.fake.calls, "\n"), "logs ")) check(t, "service stopped", "service stop", e.fake.calls[len(e.fake.calls)-1]) } func TestLaunchCustomRoot(t *testing.T) { e := setup(t) _ = os.MkdirAll(e.work+"/other/x", 0o755) writePaths(t, e, e.work+"/other") check(t, "custom root", 0, e.launch(t, e.work+"/other/x", "--exec", "true")) e.reset() check(t, "home no longer allowed", 1, e.launch(t, e.work+"/home/proj", "--exec", "true")) check(t, "podman calls", []string(nil), e.fake.calls) } func TestLaunchDotDirRoot(t *testing.T) { e := setup(t) _ = os.MkdirAll(e.work+"/home/.local/src/x", 0o755) writePaths(t, e, "~/.local/src") check(t, "listed dot-dir root", 0, e.launch(t, e.work+"/home/.local/src/x", "--exec", "true")) e.reset() _ = os.Symlink(e.work+"/home/.local/src", e.work+"/home/src") writePaths(t, e, "~/src") check(t, "symlinked root", 0, e.launch(t, e.work+"/home/src/x", "--exec", "true")) } func TestCheckLaunchDirRoots(t *testing.T) { cfg, _ := filepath.EvalSymlinks(t.TempDir()) t.Setenv("HOME", "/home/u") t.Setenv("XDG_CONFIG_HOME", cfg) t.Setenv("XDG_DATA_HOME", "/srv/data") t.Setenv("XDG_RUNTIME_DIR", "/srv/rt") t.Setenv("PATH", "/usr/bin:/srv/tools/bin") f := filepath.Join(cfg, "agent-sandbox", "allowed-paths") _ = os.MkdirAll(filepath.Dir(f), 0o755) write := func(l string) { _ = os.WriteFile(f, []byte(l+"\n"), 0o644) } write("/srv/work\n~/src") for dir, ok := range map[string]bool{"/srv/work/x": true, "/home/u/src/x": true, "/srv/other": false, "/srv/work": false, "/home/u/proj": false, "/run/user/1000": false, "/run": false, "/tmp/x": false, "/srv/work/b": true} { _, err := checkLaunchDir(dir, "/nowhere/hosts") check(t, dir, ok, err == nil) } for _, bad := range []string{"/", "/home", "~", "~/", "~/.local", "~/.local/share", "~/.ssh", "~/.ssh/x", "~/.gnupg", "~/.config/foo", "~/.local/share/containers/s", "relative/x", "/run/user/1000", "/tmp/x", "/var/tmp", "/proc/1", "/sys", "/dev/shm", "/usr", "/srv/tools", "/var"} { write("/srv/work\n" + bad) _, _, err := allowedRoots("/home/u") check(t, "invalid root "+bad, true, strings.Contains(fmt.Sprint(err), "allowed-paths")) } write("/var") _, err := checkLaunchDir("/var/tmp/x", "/nowhere/hosts") check(t, "/var/tmp under /var root", true, err != nil) // cfg lives under /tmp, so drop that rule to leave only the sensitive one. origRoots := forbiddenRoots forbiddenRoots = []string{"/run"} t.Cleanup(func() { forbiddenRoots = origRoots }) for bad, want := range map[string]string{cfg: cfg, cfg + "/agent-sandbox/x": cfg, "/srv/data/containers/x": "/srv/data/containers", "/srv/data": "/srv/data/containers", "~/.ssh/x": "/home/u/.ssh", "~/.gnupg/x": "/home/u/.gnupg", "~/.config/foo": "/home/u/.config", "~/.local/share/containers/s": "/home/u/.local/share/containers", "/srv/rt/x": "/srv/rt"} { write("/srv/work\n" + bad) _, _, err := allowedRoots("/home/u") check(t, "sensitive root "+bad, true, strings.Contains(fmt.Sprint(err), "overlaps "+want)) } } func TestLaunchPassesArgsVerbatim(t *testing.T) { cases := []struct{ args, want []string }{ {[]string{"--exec", "podman", "version"}, []string{"podman", "version"}}, {[]string{"--exec", "sh", "-c", "x --y"}, []string{"sh", "-c", "x --y"}}, {[]string{"help"}, []string{"claude", "help"}}, {[]string{"version"}, []string{"claude", "version"}}, {[]string{"--resume", "abc"}, []string{"claude", "--resume", "abc"}}, {[]string{"-p", "hi"}, []string{"claude", "-p", "hi"}}, {[]string{"-p", "--exec", "help"}, []string{"claude", "-p", "--exec", "help"}}, {[]string{"--exec", "--exec"}, []string{"--exec"}}, } for _, tc := range cases { e := setup(t) check(t, "exit", 0, e.launch(t, e.work+"/home/proj", tc.args...)) for name, s := range e.fake.specs { if strings.HasSuffix(name, "-claude") { check(t, strings.Join(tc.args, " "), tc.want, s.Command) } } } } func TestLaunchPathEntries(t *testing.T) { e := setup(t) _ = os.MkdirAll(e.work+"/home/src/x", 0o755) _ = os.MkdirAll(e.work+"/home/go/bin", 0o755) check(t, "src allowed", 0, e.launch(t, e.work+"/home/src/x", "--exec", "true")) e.reset() check(t, "go refused", 1, e.launch(t, e.work+"/home/go", "--exec", "true")) check(t, "podman calls", []string(nil), e.fake.calls) } func TestLaunchMountsResolvedSource(t *testing.T) { e := setup(t) _ = os.MkdirAll(e.work+"/home/.local/src/x", 0o755) _ = os.Symlink(e.work+"/home/.local/src", e.work+"/home/src") writePaths(t, e, "~/src") dir := e.work + "/home/src/x" check(t, "exit", 0, e.launch(t, dir, "--exec", "true")) for name, c := range e.fake.specs { if strings.HasSuffix(name, "-claude") { check(t, "mount", []spec.Mount{{Type: "bind", Source: e.work + "/home/.local/src/x", Destination: dir}}, c.Mounts) check(t, "workdir", dir, c.WorkDir) } } } func TestCheckLaunchDirDefaultRootForbidden(t *testing.T) { t.Setenv("HOME", "/tmp/home") t.Setenv("XDG_CONFIG_HOME", "/nonexistent/cfg") t.Setenv("XDG_DATA_HOME", "") t.Setenv("XDG_RUNTIME_DIR", "/nonexistent/rt") t.Setenv("PATH", "/usr/bin") _, err := checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts") check(t, "forbidden default root", "refusing to mount /tmp/home/proj: it overlaps "+realpath("/tmp"), fmt.Sprint(err)) } // isolatedHome points every sensitive dir outside a fresh $HOME, leaving only // the rule under test to refuse. func isolatedHome(t *testing.T) (work, home string) { work, _ = filepath.EvalSymlinks(t.TempDir()) elsewhere, _ := filepath.EvalSymlinks(t.TempDir()) home = work + "/home" for _, d := range []string{".ssh", ".gnupg", ".config", ".local/share/containers"} { _ = os.MkdirAll(filepath.Join(elsewhere, d), 0o755) _ = os.MkdirAll(filepath.Dir(filepath.Join(home, d)), 0o755) _ = os.Symlink(filepath.Join(elsewhere, d), filepath.Join(home, d)) } t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", elsewhere+"/cfg") t.Setenv("XDG_DATA_HOME", "") t.Setenv("XDG_RUNTIME_DIR", elsewhere+"/rt") t.Setenv("PATH", "/usr/bin") origRoots := forbiddenRoots forbiddenRoots = []string{"/run"} t.Cleanup(func() { forbiddenRoots = origRoots }) _ = os.MkdirAll(filepath.Dir(allowedPathsFile()), 0o755) return work, home } func TestCheckLaunchDirResolvedAllowedPathsDir(t *testing.T) { _, home := isolatedHome(t) dot := home + "/src/dot" _ = os.MkdirAll(dot, 0o755) _ = os.WriteFile(dot+"/allowed-paths", []byte("~/src\n"), 0o644) _ = os.Symlink(dot+"/allowed-paths", allowedPathsFile()) _ = os.MkdirAll(home+"/src/other", 0o755) _, err := checkLaunchDir(home+"/src/other", "/nonexistent/hosts") check(t, "sibling allowed", nil, err) _, err = checkLaunchDir(dot, "/nonexistent/hosts") check(t, "resolved allowed-paths dir", "refusing to mount "+dot+": it contains "+dot, fmt.Sprint(err)) } func TestAllowedRootsRejectsHomeAndDotDirs(t *testing.T) { work, home := isolatedHome(t) f := allowedPathsFile() for _, tc := range []struct{ root, want string }{ {"~/.cache", `"~/.cache" is a dot-directory of $HOME`}, {"~", `"~" is or contains $HOME`}, {home, `"` + home + `" is or contains $HOME`}, {work, `"` + work + `" is or contains $HOME`}, } { _ = os.WriteFile(f, []byte(tc.root+"\n"), 0o644) _, _, err := allowedRoots(home) check(t, tc.root, f+": "+tc.want, fmt.Sprint(err)) } _ = os.WriteFile(f, []byte("~/src\n"), 0o644) _, _, err := allowedRoots(home) check(t, "plain root", nil, err) } func TestLaunchRefusesColonInResolvedSource(t *testing.T) { e := setup(t) _ = os.Mkdir(e.work+"/home/a:b", 0o755) _ = os.Symlink(e.work+"/home/a:b", e.work+"/home/link") stderr, _ := os.CreateTemp(t.TempDir(), "stderr") orig := os.Stderr os.Stderr = stderr code := e.launch(t, e.work+"/home/link", "--exec", "true") os.Stderr = orig out, _ := os.ReadFile(stderr.Name()) check(t, "exit", 1, code) check(t, "stderr", "agent-sandbox: cannot mount a directory containing ':'\n", string(out)) check(t, "podman calls", []string(nil), e.fake.calls) }