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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user