diff --git a/README.md b/README.md index 429f846..952c462 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.go b/main.go index b7684f4..95ea95d 100644 --- a/main.go +++ b/main.go @@ -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. diff --git a/main_test.go b/main_test.go index aa25ee1..7f9c20d 100644 --- a/main_test.go +++ b/main_test.go @@ -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) + } + } +}