diff --git a/README.md b/README.md index 429f846..011877c 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ agent-sandbox --exec bash # a shell in the sandbox instead of claude - `$PWD` is mounted read-write at the same path. Files you create there are owned by your host user. Nothing else from the host is mounted, and SELinux labels on your files are never changed (`label=disable`). -- It refuses to launch from `$HOME`, any parent of it, or any directory - containing the allowlist. +- It only launches from a directory strictly under an allowed path (see + below), and never from a dot-directory of `$HOME` or a directory containing + its config. - Claude's home is the `agent-sandbox-home` podman volume (login, config and nested container images live here). All sessions share it. - `podman` works inside the sandbox (nested rootless). The host podman socket is @@ -39,6 +40,18 @@ db.example.com:5432 this exact host on TCP 5432 too (ssh, postgres, ...) You don't need a proxy setting. Allowlisted names resolve to loopback addresses inside the sandbox, and all other names return NXDOMAIN. +## Allowed paths + +`~/.config/agent-sandbox/allowed-paths` lists the roots you may launch from, +one absolute or `~/` path per line (`#` comments allowed). Without the file the +only root is `$HOME`. Roots that are `/`, a parent of `$HOME`, or under `/run`, +`/tmp`, `/var/tmp`, `/proc`, `/sys` or `/dev` abort the launch. + +``` +~/src +/srv/work +``` + ## How it works Each session gets its own `--internal` podman network with no route out, and a diff --git a/main.go b/main.go index 626f92b..e09e4f4 100644 --- a/main.go +++ b/main.go @@ -332,19 +332,76 @@ func ensureAllowlist(path string) error { return nil } -// checkLaunchDir refuses launch dirs that would mount $HOME or the allowlist -// read-write. +var forbiddenRoots = []string{"/run", "/tmp", "/var/tmp", "/proc", "/sys", "/dev"} + +func allowedPathsFile() string { + return filepath.Join(configDir(), "allowed-paths") +} + +// allowedRoots reads the launch-dir roots, defaulting to $HOME. +func allowedRoots(home string) ([]string, error) { + b, err := os.ReadFile(allowedPathsFile()) + if os.IsNotExist(err) { + return []string{home}, nil + } + if err != nil { + return nil, err + } + var roots []string + for _, l := range strings.Split(string(b), "\n") { + l = strings.TrimSpace(l) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + r := l + if l == "~" || strings.HasPrefix(l, "~/") { + r = home + l[1:] + } + if !filepath.IsAbs(r) { + return nil, fmt.Errorf("%s: %q is not an absolute path", allowedPathsFile(), l) + } + r = realpath(r) + if inside(home, r) && r != home { + return nil, fmt.Errorf("%s: %q contains $HOME", allowedPathsFile(), l) + } + for _, bad := range forbiddenRoots { + if inside(r, bad) { + return nil, fmt.Errorf("%s: %q is under %s", allowedPathsFile(), l, bad) + } + } + roots = append(roots, r) + } + return roots, nil +} + +// checkLaunchDir allows only launch dirs strictly under an allowed root, never +// a dot-dir of $HOME, $HOME itself, or anything holding the sandbox's config. func checkLaunchDir(pwd, allowlist string) error { pwd = realpath(pwd) - if inside(realpath(os.Getenv("HOME")), pwd) { + home := realpath(os.Getenv("HOME")) + roots, err := allowedRoots(home) + if err != nil { + return err + } + if inside(home, pwd) { return fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd) } - for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist))} { + if rel, ok := strings.CutPrefix(pwd, home+"/"); ok && strings.HasPrefix(rel, ".") { + return fmt.Errorf("refusing to mount %s: it is under a dot-directory of $HOME", pwd) + } + paths := allowedPathsFile() + for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)), + filepath.Dir(paths), filepath.Dir(realpath(paths))} { if dir = realpath(dir); inside(dir, pwd) { return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir) } } - return nil + for _, r := range roots { + if pwd != r && inside(pwd, r) { + return nil + } + } + return fmt.Errorf("refusing to mount %s: it is not under an allowed path (%s)", pwd, paths) } // inside reports whether path p is dir or lies under it. diff --git a/main_test.go b/main_test.go index 0d90364..c235cf1 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "net" "os" "path/filepath" @@ -209,6 +210,10 @@ func setup(t *testing.T) *env { t.Setenv("AGENT_SANDBOX_IMAGE", "") t.Setenv("AGENT_SANDBOX_ROUTER_IMAGE", "") t.Setenv("AGENT_SANDBOX_HOME_VOLUME", "") + // 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") @@ -234,6 +239,17 @@ func (e *env) launchCtx(t *testing.T, ctx context.Context, dir string, args ...s return launch(ctx, args) } +var runUser = fmt.Sprintf("/run/user/%d", os.Getuid()) + +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 @@ -267,6 +283,19 @@ func TestLaunchRefusals(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", e.work+"/home/cfglink") t.Setenv("AGENT_SANDBOX_ALLOWLIST", "") }}, + {"run-user", runUser, nil}, + {"run", "/run", nil}, + {"tmp", "/tmp", nil}, + {"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-run-user", "home/runlink", func(t *testing.T, e *env) { _ = os.Symlink(runUser, 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/proj", func(t *testing.T, e *env) { + t.Setenv("XDG_CONFIG_HOME", e.work+"/home/proj/cfg") + writePaths(t, e, "~/") + }}, + {"invalid-root", "home/proj", func(t *testing.T, e *env) { writePaths(t, e, "~/", "/run/x") }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -278,6 +307,9 @@ func TestLaunchRefusals(t *testing.T) { if !filepath.IsAbs(dir) { dir = filepath.Join(e.work, dir) } + if _, err := os.Stat(dir); err != nil { + t.Skip(err) + } check(t, "exit", 1, e.launch(t, dir, "--exec", "true")) check(t, "podman calls", []string(nil), e.fake.calls) }) @@ -471,3 +503,34 @@ func TestLaunchRouterNeverStarts(t *testing.T) { 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 TestCheckLaunchDirRoots(t *testing.T) { + cfg := t.TempDir() + t.Setenv("HOME", "/home/u") + t.Setenv("XDG_CONFIG_HOME", cfg) + 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, "/tmp/x": false} { + check(t, dir, ok, checkLaunchDir(dir, "/nowhere/hosts") == nil) + } + for _, bad := range []string{"/", "/home", "~", "relative/x", "/run/user/1000", "/tmp/x", "/var/tmp", "/proc/1", + "/sys", "/dev/shm"} { + write(bad) + check(t, "invalid root "+bad, true, strings.Contains(fmt.Sprint(checkLaunchDir("/srv/work/x", "/nowhere/hosts")), + "allowed-paths")) + } +}