Allow launches only under configured roots
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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.
This commit is contained in:
2026-10-24 17:05:33 +11:00
parent af55d83de6
commit 3ec134147a
3 changed files with 140 additions and 7 deletions
+15 -2
View File
@@ -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
+62 -5
View File
@@ -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.
+63
View File
@@ -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"))
}
}