From 3ec134147ada2efb8d62ae5d1c5f23e660bf2033 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:05:33 +1100 Subject: [PATCH 1/5] Allow launches only under configured roots Replace the launch-dir denylist with an allowlist of roots from allowed-paths (default $HOME), always refusing $HOME dot-dirs and invalid roots under /run, /tmp, /var/tmp, /proc, /sys or /dev. --- README.md | 17 +++++++++++-- main.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++++---- main_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) 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")) + } +} -- 2.47.3 From 90c7412194fb4dcfd4922b59e19cb2e272374304 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:11:33 +1100 Subject: [PATCH 2/5] Trust listed roots under dot-dirs, reject sensitive ones --- README.md | 15 +++++++++------ main.go | 48 +++++++++++++++++++++++++++++++++++------------- main_test.go | 47 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 011877c..86ca90c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,7 @@ agent-sandbox --exec bash # a shell in the sandbox instead of claude owned by your host user. Nothing else from the host is mounted, and SELinux labels on your files are never changed (`label=disable`). - 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. + below), never from `$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 @@ -43,12 +42,16 @@ 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. +one absolute or `~/` path per line (`#` comments allowed), compared after +resolving symlinks. Without the file the only root is `$HOME`, minus its +dot-directories. Listed roots are trusted even under a dot-directory, but these +abort the launch: `$HOME` or its parents; a top-level dot-directory such as +`~/.local`; anything under `/run`, `/tmp`, `/var/tmp`, `/proc`, `/sys` or +`/dev`; anything that is, holds or sits in `~/.ssh`, `~/.gnupg`, `~/.config`, +`$XDG_RUNTIME_DIR` or podman's storage (`~/.local/share/containers`). ``` -~/src +~/.local/src /srv/work ``` diff --git a/main.go b/main.go index e09e4f4..7a8c906 100644 --- a/main.go +++ b/main.go @@ -338,16 +338,29 @@ func allowedPathsFile() string { return filepath.Join(configDir(), "allowed-paths") } -// allowedRoots reads the launch-dir roots, defaulting to $HOME. -func allowedRoots(home string) ([]string, error) { +// sensitiveDirs are host dirs an allowed-paths root may neither contain nor sit under. +func sensitiveDirs(home string) []string { + dirs := []string{home + "/.ssh", home + "/.gnupg", home + "/.config", filepath.Dir(configDir()), + home + "/.local/share/containers"} + if v := os.Getenv("XDG_RUNTIME_DIR"); v != "" { + dirs = append(dirs, v) + } + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + dirs = append(dirs, v+"/containers") + } + return dirs +} + +// allowedRoots reads the launch-dir roots; without the file the only root is +// $HOME and def is true. +func allowedRoots(home string) (roots []string, def bool, err error) { b, err := os.ReadFile(allowedPathsFile()) if os.IsNotExist(err) { - return []string{home}, nil + return []string{home}, true, nil } if err != nil { - return nil, err + return nil, false, err } - var roots []string for _, l := range strings.Split(string(b), "\n") { l = strings.TrimSpace(l) if l == "" || strings.HasPrefix(l, "#") { @@ -358,35 +371,44 @@ func allowedRoots(home string) ([]string, error) { r = home + l[1:] } if !filepath.IsAbs(r) { - return nil, fmt.Errorf("%s: %q is not an absolute path", allowedPathsFile(), l) + return nil, false, 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) + if inside(home, r) { + return nil, false, fmt.Errorf("%s: %q is or contains $HOME", allowedPathsFile(), l) + } + if filepath.Dir(r) == home && strings.HasPrefix(filepath.Base(r), ".") { + return nil, false, fmt.Errorf("%s: %q is a dot-directory of $HOME", allowedPathsFile(), l) } for _, bad := range forbiddenRoots { if inside(r, bad) { - return nil, fmt.Errorf("%s: %q is under %s", allowedPathsFile(), l, bad) + return nil, false, fmt.Errorf("%s: %q is under %s", allowedPathsFile(), l, bad) + } + } + for _, s := range sensitiveDirs(home) { + if s = realpath(s); inside(r, s) || inside(s, r) { + return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, s) } } roots = append(roots, r) } - return roots, nil + return roots, false, 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. +// $HOME itself or anything holding the sandbox's config. Under the default +// $HOME root, dot-dirs of $HOME are refused too. func checkLaunchDir(pwd, allowlist string) error { pwd = realpath(pwd) home := realpath(os.Getenv("HOME")) - roots, err := allowedRoots(home) + roots, def, 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) } - if rel, ok := strings.CutPrefix(pwd, home+"/"); ok && strings.HasPrefix(rel, ".") { + if rel, ok := strings.CutPrefix(pwd, home+"/"); def && ok && strings.HasPrefix(rel, ".") { return fmt.Errorf("refusing to mount %s: it is under a dot-directory of $HOME", pwd) } paths := allowedPathsFile() diff --git a/main_test.go b/main_test.go index c235cf1..8df3e72 100644 --- a/main_test.go +++ b/main_test.go @@ -199,12 +199,13 @@ type env struct { func setup(t *testing.T) *env { work, _ := filepath.EvalSymlinks(t.TempDir()) - for _, d := range []string{"home/proj", "other"} { + 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) + 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", "") @@ -291,11 +292,15 @@ func TestLaunchRefusals(t *testing.T) { {"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, "~/") + {"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") }}, + {"default-root-dot-dir", "home/.local/src/x", func(t *testing.T, e *env) { + _ = os.MkdirAll(e.work+"/home/.local/src/x", 0o755) }}, - {"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) { @@ -369,7 +374,7 @@ func TestLaunch(t *testing.T) { yes := true r := f.specs[router] tmp := r.Mounts[0].Source - check(t, "tmp under runtime dir", true, strings.HasPrefix(tmp, e.work+"/"+session+".")) + 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} { @@ -377,7 +382,7 @@ func TestLaunch(t *testing.T) { t.Errorf("%s not removed", d) } } - leftovers(t, e.work) + leftovers(t, e.work+"/rt") check(t, "router image", "localhost/agent-sandbox-router:dev", r.Image) check(t, "router rm", &yes, r.Remove) @@ -469,14 +474,14 @@ func TestLaunchSignalCleansUp(t *testing.T) { 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) + 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) + leftovers(t, e.work+"/rt") } func TestLaunchSeedsAllowlist(t *testing.T) { @@ -514,10 +519,23 @@ func TestLaunchCustomRoot(t *testing.T) { 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 := t.TempDir() t.Setenv("HOME", "/home/u") t.Setenv("XDG_CONFIG_HOME", cfg) + t.Setenv("XDG_DATA_HOME", "") 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) } @@ -527,10 +545,11 @@ func TestCheckLaunchDirRoots(t *testing.T) { "/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", + for _, bad := range []string{"/", "/home", "~", "~/", "~/.local", "~/.local/share", "~/.ssh", "~/.ssh/x", + "~/.gnupg", "~/.config/foo", cfg, cfg + "/agent-sandbox/x", "~/.local/share/containers/s", "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")) + write("/srv/work\n" + bad) + _, _, err := allowedRoots("/home/u") + check(t, "invalid root "+bad, true, strings.Contains(fmt.Sprint(err), "allowed-paths")) } } -- 2.47.3 From f60caa7e986f33dd2fa145b87246dfbc01958f5e Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:43:06 +1100 Subject: [PATCH 3/5] Refuse launch dirs holding $PATH entries and mount the checked path --- README.md | 11 ++++++--- main.go | 57 +++++++++++++++++++++++++++++---------------- main_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 99 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 86ca90c..952c462 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,13 @@ agent-sandbox [claude args...] # e.g. agent-sandbox --continue 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 +- `$PWD` is mounted read-write at the same path (its resolved target is what + gets mounted). 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 only launches from a directory strictly under an allowed path (see - below), never from `$HOME` or a directory containing its config. + below), never from `$HOME`, a directory containing its config, or one that + is or contains a `$PATH` entry (such as `~/bin` or `~/go`). - 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 @@ -48,7 +50,8 @@ dot-directories. Listed roots are trusted even under a dot-directory, but these abort the launch: `$HOME` or its parents; a top-level dot-directory such as `~/.local`; anything under `/run`, `/tmp`, `/var/tmp`, `/proc`, `/sys` or `/dev`; anything that is, holds or sits in `~/.ssh`, `~/.gnupg`, `~/.config`, -`$XDG_RUNTIME_DIR` or podman's storage (`~/.local/share/containers`). +`$XDG_RUNTIME_DIR` or podman's storage (`~/.local/share/containers`); anything +that is or contains a `$PATH` entry. ``` ~/.local/src @@ -85,6 +88,8 @@ pod on that network that holds the sandbox's netns, DNS and hosts file. `git.unkin.net` and `api.anthropic.com` can carry your data out. `$PWD` is writable, so anything there, including `.git/hooks`, runs later on the host if you trust it. +- Files written in the launch dir can still run on the host later (git hooks, + Makefiles, `.envrc`), so review changes before running them. - Router-side DNS resolves allowlisted names on the host's resolvers, so internal names like `vault.service.consul` work. - The home volume persists across sessions and is shared by all of them. diff --git a/main.go b/main.go index 1c25d8a..e7a31f3 100644 --- a/main.go +++ b/main.go @@ -84,13 +84,14 @@ func launch(ctx context.Context, args []string) int { warn("%v", err) return 1 } - if strings.Contains(pwd, ":") { - warn("cannot mount a directory containing ':'") + allowlist := allowlistPath() + src, err := checkLaunchDir(pwd, allowlist) + if err != nil { + warn("%v", err) return 1 } - allowlist := allowlistPath() - if err := checkLaunchDir(pwd, allowlist); err != nil { - warn("%v", err) + if strings.Contains(pwd+src, ":") { + warn("cannot mount a directory containing ':'") return 1 } if err := ensureAllowlist(allowlist); err != nil { @@ -134,7 +135,7 @@ func launch(ctx context.Context, args []string) int { opCtx, cancel := context.WithCancel(conn) defer cancel() defer context.AfterFunc(ctx, cancel)() - code := runSession(opCtx, eng, session, tmp, pwd, entries, cmd) + code := runSession(opCtx, eng, session, tmp, src, pwd, entries, cmd) if ctx.Err() != nil { return 130 } @@ -147,7 +148,8 @@ func fail(err error) int { return 125 } -func runSession(ctx context.Context, eng engine, session, tmp, pwd string, entries []entry, cmd []string) int { +// runSession mounts the checked src at pwd, the path the user launched from. +func runSession(ctx context.Context, eng engine, session, tmp, src, pwd string, entries []entry, cmd []string) int { image, routerImage := imageNames() homeVolume := envOr("AGENT_SANDBOX_HOME_VOLUME", "agent-sandbox-home") router := session + "-router" @@ -257,7 +259,7 @@ func runSession(ctx context.Context, eng engine, session, tmp, pwd string, entri c.Annotations = map[string]string{define.InspectAnnotationLabel: "disable"} c.Devices = []spec.LinuxDevice{{Path: "/dev/fuse"}, {Path: "/dev/net/tun"}} c.Volumes = []*specgen.NamedVolume{{Name: homeVolume, Dest: "/home/claude"}} - c.Mounts = []spec.Mount{{Type: "bind", Source: pwd, Destination: pwd}} + c.Mounts = []spec.Mount{{Type: "bind", Source: src, Destination: pwd}} c.WorkDir = pwd c.Env = map[string]string{"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_TMPDIR": "/var/tmp"} c.Command = cmd @@ -371,6 +373,11 @@ func allowedRoots(home string) (roots []string, def bool, err error) { return nil, false, fmt.Errorf("%s: %q is under %s", allowedPathsFile(), l, bad) } } + for _, d := range pathDirs() { + if inside(d, r) { + return nil, false, fmt.Errorf("%s: %q is or contains $PATH entry %s", allowedPathsFile(), l, d) + } + } for _, s := range sensitiveDirs(home) { if s = realpath(s); inside(r, s) || inside(s, r) { return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, s) @@ -381,35 +388,45 @@ func allowedRoots(home string) (roots []string, def bool, err error) { return roots, false, nil } -// checkLaunchDir allows only launch dirs strictly under an allowed root, never -// $HOME itself or anything holding the sandbox's config or the API socket. -// Under the default $HOME root, dot-dirs of $HOME are refused too. -func checkLaunchDir(pwd, allowlist string) error { +// pathDirs resolves $PATH; a sandbox-written file there would run on the host. +func pathDirs() []string { + var dirs []string + for _, d := range filepath.SplitList(os.Getenv("PATH")) { + dirs = append(dirs, realpath(d)) + } + return dirs +} + +// checkLaunchDir returns pwd resolved if it is strictly under an allowed root, +// never $HOME itself or anything holding the sandbox's config, the API socket +// or a $PATH entry. Under the default $HOME root, dot-dirs of $HOME are +// refused too. +func checkLaunchDir(pwd, allowlist string) (string, error) { pwd = realpath(pwd) home := realpath(os.Getenv("HOME")) roots, def, err := allowedRoots(home) if err != nil { - return err + return "", err } if inside(home, pwd) { - return fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd) + return "", fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd) } if rel, ok := strings.CutPrefix(pwd, home+"/"); def && ok && strings.HasPrefix(rel, ".") { - return fmt.Errorf("refusing to mount %s: it is under a dot-directory of $HOME", pwd) + 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)), runtimeDir()} { + for _, dir := range append([]string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)), + filepath.Dir(paths), filepath.Dir(realpath(paths)), runtimeDir()}, pathDirs()...) { if dir = realpath(dir); inside(dir, pwd) { - return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir) + return "", fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir) } } for _, r := range roots { if pwd != r && inside(pwd, r) { - return nil + return pwd, nil } } - return fmt.Errorf("refusing to mount %s: it is not under an allowed path (%s)", pwd, paths) + 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 fb369ad..db257ca 100644 --- a/main_test.go +++ b/main_test.go @@ -211,6 +211,7 @@ func setup(t *testing.T) *env { 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"} @@ -240,8 +241,6 @@ 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() @@ -298,12 +297,26 @@ 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}, + {"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-run-user", "home/runlink", func(t *testing.T, e *env) { _ = os.Symlink(runUser, e.work+"/home/runlink") }}, + {"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) { @@ -327,7 +340,7 @@ func TestLaunchRefusals(t *testing.T) { dir = filepath.Join(e.work, dir) } if _, err := os.Stat(dir); err != nil { - t.Skip(err) + t.Fatal(err) } check(t, "exit", 1, e.launch(t, dir, "--exec", "true")) check(t, "podman calls", []string(nil), e.fake.calls) @@ -339,12 +352,14 @@ func TestCheckLaunchDirRuntimeFallback(t *testing.T) { t.Setenv("HOME", "/nonexistent/home") t.Setenv("XDG_CONFIG_HOME", "/nonexistent/cfg") t.Setenv("XDG_RUNTIME_DIR", "") - err := checkLaunchDir("/tmp", "/nonexistent/hosts") + 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") - check(t, "subdir", nil, checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts")) + _, err = checkLaunchDir("/tmp/home/proj", "/nonexistent/hosts") + check(t, "subdir", nil, err) } func leftovers(t *testing.T, dir string) { @@ -562,18 +577,21 @@ func TestCheckLaunchDirRoots(t *testing.T) { t.Setenv("HOME", "/home/u") t.Setenv("XDG_CONFIG_HOME", cfg) t.Setenv("XDG_DATA_HOME", "") + 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, "/tmp/x": false} { - check(t, dir, ok, checkLaunchDir(dir, "/nowhere/hosts") == nil) + "/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", cfg, cfg + "/agent-sandbox/x", "~/.local/share/containers/s", "relative/x", "/run/user/1000", "/tmp/x", "/var/tmp", "/proc/1", - "/sys", "/dev/shm"} { + "/sys", "/dev/shm", "/usr", "/srv/tools"} { write("/srv/work\n" + bad) _, _, err := allowedRoots("/home/u") check(t, "invalid root "+bad, true, strings.Contains(fmt.Sprint(err), "allowed-paths")) @@ -601,3 +619,28 @@ func TestLaunchPassesArgsVerbatim(t *testing.T) { } } } + +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) + } + } +} -- 2.47.3 From 80c90769fd061e3fd3960c7ef5e5171403a98a97 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:49:13 +1100 Subject: [PATCH 4/5] Refuse roots and launch dirs overlapping forbidden system dirs --- main.go | 17 +++++++++++------ main_test.go | 11 ++++++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index e7a31f3..823a691 100644 --- a/main.go +++ b/main.go @@ -369,8 +369,8 @@ func allowedRoots(home string) (roots []string, def bool, err error) { return nil, false, fmt.Errorf("%s: %q is a dot-directory of $HOME", allowedPathsFile(), l) } for _, bad := range forbiddenRoots { - if inside(r, bad) { - return nil, false, fmt.Errorf("%s: %q is under %s", allowedPathsFile(), l, bad) + if bad = realpath(bad); inside(r, bad) || inside(bad, r) { + return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, bad) } } for _, d := range pathDirs() { @@ -398,9 +398,9 @@ func pathDirs() []string { } // checkLaunchDir returns pwd resolved if it is strictly under an allowed root, -// never $HOME itself or anything holding the sandbox's config, the API socket -// or a $PATH entry. Under the default $HOME root, dot-dirs of $HOME are -// refused too. +// never $HOME itself, anything holding the sandbox's config, the API socket or +// a $PATH entry, nor anything overlapping a forbidden system dir. Under the +// default $HOME root, dot-dirs of $HOME are refused too. func checkLaunchDir(pwd, allowlist string) (string, error) { pwd = realpath(pwd) home := realpath(os.Getenv("HOME")) @@ -416,11 +416,16 @@ func checkLaunchDir(pwd, allowlist string) (string, error) { } paths := allowedPathsFile() for _, dir := range append([]string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)), - filepath.Dir(paths), filepath.Dir(realpath(paths)), runtimeDir()}, pathDirs()...) { + filepath.Dir(realpath(paths)), runtimeDir()}, pathDirs()...) { if dir = realpath(dir); inside(dir, pwd) { return "", fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir) } } + for _, bad := range forbiddenRoots { + if bad = realpath(bad); inside(pwd, bad) || inside(bad, pwd) { + return "", fmt.Errorf("refusing to mount %s: it overlaps %s", pwd, bad) + } + } for _, r := range roots { if pwd != r && inside(pwd, r) { return pwd, nil diff --git a/main_test.go b/main_test.go index db257ca..753e279 100644 --- a/main_test.go +++ b/main_test.go @@ -325,6 +325,7 @@ func TestLaunchRefusals(t *testing.T) { 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") }}, {"default-root-dot-dir", "home/.local/src/x", func(t *testing.T, e *env) { _ = os.MkdirAll(e.work+"/home/.local/src/x", 0o755) }}, @@ -359,6 +360,11 @@ func TestCheckLaunchDirRuntimeFallback(t *testing.T) { } 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, "subdir", nil, err) } @@ -591,11 +597,14 @@ func TestCheckLaunchDirRoots(t *testing.T) { } for _, bad := range []string{"/", "/home", "~", "~/", "~/.local", "~/.local/share", "~/.ssh", "~/.ssh/x", "~/.gnupg", "~/.config/foo", cfg, cfg + "/agent-sandbox/x", "~/.local/share/containers/s", "relative/x", "/run/user/1000", "/tmp/x", "/var/tmp", "/proc/1", - "/sys", "/dev/shm", "/usr", "/srv/tools"} { + "/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) } func TestLaunchPassesArgsVerbatim(t *testing.T) { -- 2.47.3 From 9958b5557e6987b5ff57a2b3eb28e01d3333f6f1 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:56:54 +1100 Subject: [PATCH 5/5] Refuse launch dirs overlapping sensitive dirs under every root --- main.go | 21 ++++++++++++++++----- main_test.go | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/main.go b/main.go index 823a691..95ea95d 100644 --- a/main.go +++ b/main.go @@ -339,6 +339,16 @@ func sensitiveDirs(home string) []string { return dirs } +// overlapsSensitive returns the sensitive dir that p is, sits under or contains. +func overlapsSensitive(p, home string) string { + for _, s := range sensitiveDirs(home) { + if s = realpath(s); inside(p, s) || inside(s, p) { + return s + } + } + return "" +} + // allowedRoots reads the launch-dir roots; without the file the only root is // $HOME and def is true. func allowedRoots(home string) (roots []string, def bool, err error) { @@ -378,10 +388,8 @@ func allowedRoots(home string) (roots []string, def bool, err error) { return nil, false, fmt.Errorf("%s: %q is or contains $PATH entry %s", allowedPathsFile(), l, d) } } - for _, s := range sensitiveDirs(home) { - if s = realpath(s); inside(r, s) || inside(s, r) { - return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, s) - } + if s := overlapsSensitive(r, home); s != "" { + return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, s) } roots = append(roots, r) } @@ -399,7 +407,7 @@ func pathDirs() []string { // checkLaunchDir returns pwd resolved if it is strictly under an allowed root, // never $HOME itself, anything holding the sandbox's config, the API socket or -// a $PATH entry, nor anything overlapping a forbidden system dir. Under the +// a $PATH entry, nor anything overlapping a forbidden system or sensitive dir. Under the // default $HOME root, dot-dirs of $HOME are refused too. func checkLaunchDir(pwd, allowlist string) (string, error) { pwd = realpath(pwd) @@ -426,6 +434,9 @@ func checkLaunchDir(pwd, allowlist string) (string, error) { return "", fmt.Errorf("refusing to mount %s: it overlaps %s", pwd, bad) } } + if s := overlapsSensitive(pwd, home); s != "" { + return "", fmt.Errorf("refusing to mount %s: it overlaps %s", pwd, s) + } for _, r := range roots { if pwd != r && inside(pwd, r) { return pwd, nil diff --git a/main_test.go b/main_test.go index 753e279..7f9c20d 100644 --- a/main_test.go +++ b/main_test.go @@ -326,6 +326,18 @@ func TestLaunchRefusals(t *testing.T) { }}, {"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) }}, @@ -365,6 +377,9 @@ func TestCheckLaunchDirRuntimeFallback(t *testing.T) { 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) } @@ -579,10 +594,11 @@ func TestLaunchDotDirRoot(t *testing.T) { } func TestCheckLaunchDirRoots(t *testing.T) { - cfg := t.TempDir() + cfg, _ := filepath.EvalSymlinks(t.TempDir()) t.Setenv("HOME", "/home/u") t.Setenv("XDG_CONFIG_HOME", cfg) - t.Setenv("XDG_DATA_HOME", "") + 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) @@ -596,7 +612,7 @@ func TestCheckLaunchDirRoots(t *testing.T) { check(t, dir, ok, err == nil) } for _, bad := range []string{"/", "/home", "~", "~/", "~/.local", "~/.local/share", "~/.ssh", "~/.ssh/x", - "~/.gnupg", "~/.config/foo", cfg, cfg + "/agent-sandbox/x", "~/.local/share/containers/s", "relative/x", "/run/user/1000", "/tmp/x", "/var/tmp", "/proc/1", + "~/.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") @@ -605,6 +621,18 @@ func TestCheckLaunchDirRoots(t *testing.T) { 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) { -- 2.47.3