Allow launches only under configured roots #3

Merged
benvin merged 8 commits from benvin/launch-dir-allowlist into main 2026-10-24 20:40:16 +11:00
3 changed files with 322 additions and 29 deletions
+24 -3
View File
@@ -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.
+127 -18
View File
@@ -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
View File
@@ -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)
}
}
}