Trust listed roots under dot-dirs, reject sensitive ones
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

This commit is contained in:
2026-10-24 17:11:33 +11:00
parent 3ec134147a
commit 90c7412194
3 changed files with 77 additions and 33 deletions
+9 -6
View File
@@ -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
```
+35 -13
View File
@@ -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()
+33 -14
View File
@@ -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"))
}
}