Allow launches only under configured roots #3
@@ -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 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), 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
|
||||
@@ -39,6 +41,23 @@ 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), 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`); anything
|
||||
that is or contains a `$PATH` entry.
|
||||
|
||||
```
|
||||
~/.local/src
|
||||
/srv/work
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
Each session gets its own `--internal` podman network with no route out, and a
|
||||
@@ -69,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.
|
||||
|
||||
@@ -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
|
||||
@@ -321,19 +323,126 @@ func ensureAllowlist(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkLaunchDir refuses launch dirs that would mount $HOME, the allowlist or
|
||||
// the podman API socket read-write.
|
||||
func checkLaunchDir(pwd, allowlist string) error {
|
||||
pwd = realpath(pwd)
|
||||
if inside(realpath(os.Getenv("HOME")), pwd) {
|
||||
return fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd)
|
||||
var forbiddenRoots = []string{"/run", "/tmp", "/var/tmp", "/proc", "/sys", "/dev"}
|
||||
|
||||
func allowedPathsFile() string {
|
||||
return filepath.Join(configDir(), "allowed-paths")
|
||||
}
|
||||
|
||||
// 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", runtimeDir()}
|
||||
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
|
||||
dirs = append(dirs, v+"/containers")
|
||||
}
|
||||
for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)), runtimeDir()} {
|
||||
if dir = realpath(dir); inside(dir, pwd) {
|
||||
return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir)
|
||||
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 nil
|
||||
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) {
|
||||
b, err := os.ReadFile(allowedPathsFile())
|
||||
if os.IsNotExist(err) {
|
||||
return []string{home}, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
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, false, fmt.Errorf("%s: %q is not an absolute path", allowedPathsFile(), l)
|
||||
}
|
||||
r = realpath(r)
|
||||
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 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() {
|
||||
if inside(d, r) {
|
||||
return nil, false, fmt.Errorf("%s: %q is or contains $PATH entry %s", allowedPathsFile(), l, d)
|
||||
}
|
||||
}
|
||||
if s := overlapsSensitive(r, home); s != "" {
|
||||
return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, s)
|
||||
}
|
||||
roots = append(roots, r)
|
||||
}
|
||||
return roots, false, nil
|
||||
}
|
||||
|
||||
// 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, anything holding the sandbox's config, the API socket or
|
||||
// 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)
|
||||
home := realpath(os.Getenv("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+"/"); def && 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 append([]string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)),
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
+171
-8
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -198,17 +199,23 @@ 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", "")
|
||||
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"}
|
||||
t.Cleanup(func() { forbiddenRoots = origRoots })
|
||||
orig := startService
|
||||
startService = func(dir string) (context.Context, engine, func(), error) {
|
||||
e.fake.call("service start")
|
||||
@@ -234,6 +241,15 @@ func (e *env) launchCtx(t *testing.T, ctx context.Context, dir string, args ...s
|
||||
return launch(ctx, args)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -281,6 +297,50 @@ func TestLaunchRefusals(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", e.work+"/home/cfglink")
|
||||
t.Setenv("AGENT_SANDBOX_ALLOWLIST", "")
|
||||
}},
|
||||
{"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-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) {
|
||||
_ = 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") }},
|
||||
{"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)
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -292,6 +352,9 @@ func TestLaunchRefusals(t *testing.T) {
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(e.work, dir)
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(t, "exit", 1, e.launch(t, dir, "--exec", "true"))
|
||||
check(t, "podman calls", []string(nil), e.fake.calls)
|
||||
})
|
||||
@@ -302,11 +365,22 @@ 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)
|
||||
}
|
||||
check(t, "subdir", nil, checkLaunchDir("/tmp/proj", "/nonexistent/hosts"))
|
||||
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, "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)
|
||||
}
|
||||
|
||||
func leftovers(t *testing.T, dir string) {
|
||||
@@ -362,7 +436,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} {
|
||||
@@ -370,7 +444,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)
|
||||
@@ -462,14 +536,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) {
|
||||
@@ -497,6 +571,70 @@ func TestLaunchRouterNeverStarts(t *testing.T) {
|
||||
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 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, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
t.Setenv("HOME", "/home/u")
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
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)
|
||||
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, "/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", "~/.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")
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
cases := []struct{ args, want []string }{
|
||||
{[]string{"--exec", "podman", "version"}, []string{"podman", "version"}},
|
||||
@@ -518,3 +656,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user