From 1ff5b78623fe6f3d412cb55232e81085347ee267 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 23:37:31 -0700 Subject: [PATCH 1/5] feat(setup): run the setup agent in a macOS sandbox The agent runs shell commands without approvals, and OpenCode's rules limit only its file tools. In a test run, the agent searched the whole disk with find, read files in another repo, and read the ldcli access token with `ldcli config get`. On macOS, ldcli now starts OpenCode with sandbox-exec and a Seatbelt profile for each run. The sandbox applies to OpenCode and to every command that it runs. It denies the home folder, except the project, OpenCode's own folders, package caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay unreadable. System folders and the network stay open. The profile uses resolved paths, because Seatbelt matches the real path behind a symlink. OpenCode's rules also deny `ldcli` and `npx skills` now. Linux and Windows run without a sandbox for now. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/opencode_config.go | 4 + internal/setup/agent/sandbox.go | 230 ++++++++++++++++++++++++ internal/setup/agent/sandbox_test.go | 108 +++++++++++ internal/setup/agent/server.go | 16 +- 4 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 internal/setup/agent/sandbox.go create mode 100644 internal/setup/agent/sandbox_test.go diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go index 8b35363f..70d34885 100644 --- a/internal/setup/agent/opencode_config.go +++ b/internal/setup/agent/opencode_config.go @@ -41,6 +41,10 @@ func openCodeConfig() ([]byte, error) { // after it. var bashRules = [][2]string{ {"*", "allow"}, + // ldcli and the skills installer reach LaunchDarkly or change the repo + // outside the agent's task. The CLI does the LaunchDarkly work itself. + {"ldcli*", "deny"}, + {"npx skills*", "deny"}, {"git commit*", "deny"}, {"git push*", "deny"}, {"git reset*", "deny"}, diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go new file mode 100644 index 00000000..58a4af58 --- /dev/null +++ b/internal/setup/agent/sandbox.go @@ -0,0 +1,230 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// The setup agent runs commands without approvals, and OpenCode's rules limit +// only its file tools, not the paths that a shell command touches. So on +// macOS, ldcli starts OpenCode in a Seatbelt sandbox (sandbox-exec), which +// applies to OpenCode and to every command that it runs. The sandbox denies +// the user's home folder, except the project, OpenCode's own folders, package +// caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay +// unreadable. System folders and the network stay open, because the model API +// and the package registries need them. + +const sandboxExec = "/usr/bin/sandbox-exec" + +// sandboxPaths are the folders under the home folder that the sandbox opens. +type sandboxPaths struct { + Home string + // ReadWrite are folders that the agent and its commands can change. + ReadWrite []string + // ReadOnly are folders that commands must read but not change. + ReadOnly []string +} + +// readWriteUnderHome are package caches and OpenCode's own folders, relative to +// the home folder. A package install writes to its cache, and OpenCode writes +// its sessions and snapshots. +var readWriteUnderHome = []string{ + ".local/share/opencode", + ".local/state/opencode", + ".cache/opencode", + ".cache/uv", + ".cache/pip", + ".cache/pypoetry", + ".cache/go-build", + ".npm", + ".bun/install/cache", + ".gradle", + ".m2", + ".cargo/registry", + ".cargo/git", + "go/pkg/mod", + "Library/Caches/pip", + "Library/Caches/pypoetry", + "Library/Caches/go-build", + "Library/Caches/Yarn", + "Library/Caches/pnpm", + "Library/pnpm", +} + +// readOnlyUnderHome are toolchains and settings that commands read. +var readOnlyUnderHome = []string{ + ".config/opencode", + ".local/bin", + ".local/share/uv", + ".local/share/mise", + ".pyenv", + ".rbenv", + ".asdf", + ".nvm", + ".volta", + ".bun/bin", + ".deno", + ".rustup", + ".cargo/bin", + "go/bin", + ".sdkman", + ".gitconfig", + ".config/git", +} + +// buildSandboxPaths returns the folders to open for a run in dir. opencodeDir +// is the folder that holds the OpenCode program, so a copy installed under +// the home folder can still start. +func buildSandboxPaths(home, dir, opencodeDir string, env []string) sandboxPaths { + p := sandboxPaths{Home: home, ReadWrite: []string{dir}} + for _, rel := range readWriteUnderHome { + p.ReadWrite = append(p.ReadWrite, filepath.Join(home, rel)) + } + for _, rel := range readOnlyUnderHome { + p.ReadOnly = append(p.ReadOnly, filepath.Join(home, rel)) + } + // OpenCode follows the XDG folder variables. If they point elsewhere, + // open only OpenCode's folder in each. + for name, mode := range map[string]string{"XDG_DATA_HOME": "rw", "XDG_STATE_HOME": "rw", "XDG_CACHE_HOME": "rw", "XDG_CONFIG_HOME": "ro"} { + value := envValue(env, name) + if value == "" { + continue + } + folder := filepath.Join(value, "opencode") + if mode == "rw" { + p.ReadWrite = append(p.ReadWrite, folder) + } else { + p.ReadOnly = append(p.ReadOnly, folder) + } + } + if opencodeDir != "" { + p.ReadOnly = append(p.ReadOnly, opencodeDir) + } + return p +} + +// seatbeltProfile returns the sandbox profile. Later rules take precedence, +// so the allow rules for the open folders come after the deny rule for the +// home folder. +func seatbeltProfile(p sandboxPaths) string { + var b strings.Builder + b.WriteString("(version 1)\n(allow default)\n") + fmt.Fprintf(&b, "(deny file-read* file-write* (subpath %s))\n", sbString(p.Home)) + // Tools read the metadata of the home folder itself, for example to + // resolve a path, without reading what is in it. + fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) + for _, path := range p.ReadOnly { + fmt.Fprintf(&b, "(allow file-read* (subpath %s))\n", sbString(path)) + } + for _, path := range p.ReadWrite { + fmt.Fprintf(&b, "(allow file-read* file-write* (subpath %s))\n", sbString(path)) + } + return b.String() +} + +// sbString quotes a path for a Seatbelt profile. +func sbString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +func envValue(env []string, name string) string { + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok && k == name { + return v + } + } + return "" +} + +// installDir returns the folder to open for the OpenCode program at path. The +// program is often a launcher in the bin folder of its own install, such as +// .../node_modules/opencode-ai/bin, so the folder above bin holds the rest of +// the install. A shared bin folder, such as ~/.local/bin, opens only itself, +// so other programs' data stays closed. +func installDir(home, path string) string { + bin := filepath.Dir(path) + if filepath.Base(bin) != "bin" { + return bin + } + root := filepath.Dir(bin) + shared := map[string]bool{ + home: true, + filepath.Join(home, ".local"): true, + "/usr": true, + "/usr/local": true, + "/opt/homebrew": true, + } + // Never open a folder that holds the home folder, such as / or /Users. + containsHome := root == home || strings.HasPrefix(home, strings.TrimSuffix(root, "/")+"/") + if shared[root] || containsHome { + return bin + } + return root +} + +// realPath resolves the symlinks in path. A path that does not exist yet keeps +// its resolved parent, so a cache folder that a command creates later is +// still covered. +func realPath(path string) string { + if real, err := filepath.EvalSymlinks(path); err == nil { + return real + } + parent := filepath.Dir(path) + if parent == path { + return path + } + return filepath.Join(realPath(parent), filepath.Base(path)) +} + +func mapPaths(paths []string, f func(string) string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = f(p) + } + return out +} + +// sandboxCommand returns the program and arguments that run binary with args +// in the sandbox, and a function that removes the profile file. If the +// sandbox is not available, it returns binary and args unchanged. +func sandboxCommand(binary string, args []string, dir string, env []string) (string, []string, func(), error) { + noop := func() {} + if runtime.GOOS != "darwin" { + return binary, args, noop, nil + } + if _, err := os.Stat(sandboxExec); err != nil { + return binary, args, noop, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", nil, noop, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) + } + opencodeDir := "" + if path, err := exec.LookPath(binary); err == nil { + if real, err := filepath.EvalSymlinks(path); err == nil { + opencodeDir = installDir(home, real) + } + } + // Seatbelt matches the real path, so a folder behind a symlink, such as + // /var/folders on macOS, must be written as the folder it points to. + paths := buildSandboxPaths(realPath(home), realPath(dir), opencodeDir, env) + paths.ReadWrite = mapPaths(paths.ReadWrite, realPath) + paths.ReadOnly = mapPaths(paths.ReadOnly, realPath) + profile := seatbeltProfile(paths) + f, err := os.CreateTemp("", "ldcli-agent-*.sb") + if err != nil { + return "", nil, noop, err + } + if _, err := f.WriteString(profile); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, noop, err + } + f.Close() + cleanup := func() { _ = os.Remove(f.Name()) } + return sandboxExec, append([]string{"-f", f.Name(), binary}, args...), cleanup, nil +} diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go new file mode 100644 index 00000000..aa1eb19c --- /dev/null +++ b/internal/setup/agent/sandbox_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeatbeltProfile_DeniesHomeThenOpensFolders(t *testing.T) { + profile := seatbeltProfile(sandboxPaths{ + Home: "/Users/me", + ReadWrite: []string{"/Users/me/app"}, + ReadOnly: []string{`/Users/me/odd "name"`}, + }) + + deny := strings.Index(profile, `(deny file-read* file-write* (subpath "/Users/me"))`) + allow := strings.Index(profile, `(allow file-read* file-write* (subpath "/Users/me/app"))`) + require.NotEqual(t, -1, deny) + require.NotEqual(t, -1, allow) + assert.Less(t, deny, allow, "later rules win, so the allows must follow the deny") + assert.Contains(t, profile, `(allow file-read* (subpath "/Users/me/odd \"name\""))`, "paths are quoted") +} + +func TestBuildSandboxPaths_OpensOnlyOpenCodesXDGFolders(t *testing.T) { + p := buildSandboxPaths("/Users/me", "/Users/me/app", "", []string{"XDG_CONFIG_HOME=/Users/me/.ldcli-local"}) + + assert.Contains(t, p.ReadWrite, "/Users/me/app") + assert.Contains(t, p.ReadWrite, "/Users/me/.local/share/opencode") + assert.Contains(t, p.ReadOnly, "/Users/me/.ldcli-local/opencode") + assert.NotContains(t, p.ReadOnly, "/Users/me/.ldcli-local", "the ldcli configuration next to it stays closed") + for _, path := range append(p.ReadOnly, p.ReadWrite...) { + assert.NotEqual(t, "/Users/me/.config", path) + assert.NotEqual(t, "/Users/me/.config/ldcli", path) + } +} + +func TestInstallDir(t *testing.T) { + assert.Equal(t, "/opt/homebrew/lib/node_modules/opencode-ai", installDir("/Users/me", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.exe")) + assert.Equal(t, "/Users/me/.opencode", installDir("/Users/me", "/Users/me/.opencode/bin/opencode")) + assert.Equal(t, "/Users/me/.local/bin", installDir("/Users/me", "/Users/me/.local/bin/opencode"), "a shared bin folder opens only itself") + assert.Equal(t, "/usr/local/bin", installDir("/Users/me", "/usr/local/bin/opencode")) + assert.Equal(t, "/bin", installDir("/Users/me", "/bin/opencode"), "never open / or another folder that holds the home folder") + assert.Equal(t, "/Users/bin", installDir("/Users/me", "/Users/bin/opencode")) +} + +func TestOpenCodeConfig_DeniesLDCLIAndSkillsInstalls(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + assert.Contains(t, string(data), `"ldcli*":"deny"`) + assert.Contains(t, string(data), `"npx skills*":"deny"`) +} + +// TestSandboxCommand_ConfinesReadsToTheProject runs a real sandboxed command on +// macOS, with a fake home folder. +func TestSandboxCommand_ConfinesReadsToTheProject(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "code", "app") + write := func(path, content string) string { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path + } + inside := write(filepath.Join(project, "main.py"), "inside") + secrets := []string{ + write(filepath.Join(home, ".config", "ldcli", "config.yml"), "access-token: api-secret"), + write(filepath.Join(home, ".ssh", "id_ed25519"), "key"), + write(filepath.Join(home, "code", "other-repo", "main.py"), "other"), + } + + run := func(path string) (string, error) { + name, args, cleanup, err := sandboxCommand("/bin/cat", []string{path}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + require.Equal(t, sandboxExec, name) + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err + } + + out, err := run(inside) + require.NoError(t, err, out) + assert.Equal(t, "inside", out) + for _, path := range secrets { + out, err := run(path) + assert.Error(t, err, "read %s", path) + assert.Contains(t, out, "Operation not permitted", path) + } + + name, args, cleanup, err := sandboxCommand("/usr/bin/touch", []string{filepath.Join(project, "new.txt")}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + out2, err := exec.Command(name, args...).CombinedOutput() + require.NoError(t, err, string(out2)) + assert.FileExists(t, filepath.Join(project, "new.txt"), "the project stays writable") +} diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index 9424720a..66efcba0 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -46,13 +46,19 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve return nil, err } - procCtx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(procCtx, binary, "serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)) - cmd.Dir = dir - cmd.Env = childEnv(os.Environ(), map[string]string{ + env := childEnv(os.Environ(), map[string]string{ "OPENCODE_SERVER_PASSWORD": password, "OPENCODE_CONFIG_CONTENT": string(config), }) + name, args, cleanup, err := sandboxCommand(binary, []string{"serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)}, dir, env) + if err != nil { + return nil, err + } + + procCtx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(procCtx, name, args...) + cmd.Dir = dir + cmd.Env = env // Ask OpenCode to stop so it can end its own child processes, and kill it if // it does not stop in time. cmd.Cancel = func() error { @@ -68,6 +74,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve if err := cmd.Start(); err != nil { cancel() + cleanup() return nil, fmt.Errorf("could not start OpenCode: %w", err) } s := &server{ @@ -83,6 +90,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve } go func() { _ = cmd.Wait() + cleanup() close(s.exited) }() From daacd268e63e25fc2202fff65d5d0c16448418d5 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 11:46:00 -0700 Subject: [PATCH 2/5] fix(setup): let sandboxed Go commands read the Go settings file The sandbox now opens Library/Application Support/go/env for reading, and the file that GOENV names. Go commands in the sandbox then use the user's GOPROXY and GOPRIVATE. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/sandbox.go | 6 ++++++ internal/setup/agent/sandbox_test.go | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go index 58a4af58..4a762db8 100644 --- a/internal/setup/agent/sandbox.go +++ b/internal/setup/agent/sandbox.go @@ -74,6 +74,8 @@ var readOnlyUnderHome = []string{ ".sdkman", ".gitconfig", ".config/git", + // Go reads user settings, such as GOPROXY and GOPRIVATE, from this file. + "Library/Application Support/go/env", } // buildSandboxPaths returns the folders to open for a run in dir. opencodeDir @@ -101,6 +103,10 @@ func buildSandboxPaths(home, dir, opencodeDir string, env []string) sandboxPaths p.ReadOnly = append(p.ReadOnly, folder) } } + // GOENV moves the Go settings file. The value "off" turns the file off. + if goenv := envValue(env, "GOENV"); goenv != "" && goenv != "off" { + p.ReadOnly = append(p.ReadOnly, goenv) + } if opencodeDir != "" { p.ReadOnly = append(p.ReadOnly, opencodeDir) } diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go index aa1eb19c..efaea5ec 100644 --- a/internal/setup/agent/sandbox_test.go +++ b/internal/setup/agent/sandbox_test.go @@ -40,6 +40,17 @@ func TestBuildSandboxPaths_OpensOnlyOpenCodesXDGFolders(t *testing.T) { } } +func TestBuildSandboxPaths_OpensTheGoSettingsFile(t *testing.T) { + p := buildSandboxPaths("/Users/me", "/Users/me/app", "", nil) + assert.Contains(t, p.ReadOnly, "/Users/me/Library/Application Support/go/env") + + p = buildSandboxPaths("/Users/me", "/Users/me/app", "", []string{"GOENV=/Users/me/goenv"}) + assert.Contains(t, p.ReadOnly, "/Users/me/goenv") + + p = buildSandboxPaths("/Users/me", "/Users/me/app", "", []string{"GOENV=off"}) + assert.NotContains(t, p.ReadOnly, "off") +} + func TestInstallDir(t *testing.T) { assert.Equal(t, "/opt/homebrew/lib/node_modules/opencode-ai", installDir("/Users/me", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.exe")) assert.Equal(t, "/Users/me/.opencode", installDir("/Users/me", "/Users/me/.opencode/bin/opencode")) From 3f4e900f315bd05faf2bb0b33734af2184818bc3 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 12:02:07 -0700 Subject: [PATCH 3/5] fix(setup): let the sandbox create missing cache and OpenCode folders On first use, a folder such as ~/.local/share/opencode or ~/.cache/uv can be missing, and the sandbox denied the creation of its parents. The profile now allows only the creation of those parent folders. Nothing else can be written in them. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/sandbox.go | 21 +++++++++++++++++++ internal/setup/agent/sandbox_test.go | 31 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go index 4a762db8..64f006a9 100644 --- a/internal/setup/agent/sandbox.go +++ b/internal/setup/agent/sandbox.go @@ -123,6 +123,11 @@ func seatbeltProfile(p sandboxPaths) string { // Tools read the metadata of the home folder itself, for example to // resolve a path, without reading what is in it. fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) + // A cache or OpenCode folder can be missing on first use. Its missing + // parents can be created, but nothing else can be written in them. + for _, parent := range writeParents(p) { + fmt.Fprintf(&b, "(allow file-read-metadata file-write-create (literal %s))\n", sbString(parent)) + } for _, path := range p.ReadOnly { fmt.Fprintf(&b, "(allow file-read* (subpath %s))\n", sbString(path)) } @@ -132,6 +137,22 @@ func seatbeltProfile(p sandboxPaths) string { return b.String() } +// writeParents returns the folders between each read-write folder and the +// home folder, without the home folder and without repeats. +func writeParents(p sandboxPaths) []string { + seen := map[string]bool{} + var out []string + for _, path := range p.ReadWrite { + for dir := filepath.Dir(path); dir != p.Home && strings.HasPrefix(dir, p.Home+"/"); dir = filepath.Dir(dir) { + if !seen[dir] { + seen[dir] = true + out = append(out, dir) + } + } + } + return out +} + // sbString quotes a path for a Seatbelt profile. func sbString(s string) string { return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go index efaea5ec..c3dd8931 100644 --- a/internal/setup/agent/sandbox_test.go +++ b/internal/setup/agent/sandbox_test.go @@ -117,3 +117,34 @@ func TestSandboxCommand_ConfinesReadsToTheProject(t *testing.T) { require.NoError(t, err, string(out2)) assert.FileExists(t, filepath.Join(project, "new.txt"), "the project stays writable") } + +func TestSandboxCommand_CreatesMissingCacheFolders(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "app") + require.NoError(t, os.MkdirAll(project, 0o700)) + realHome, err := filepath.EvalSymlinks(home) + require.NoError(t, err) + + sandboxed := func(binary string, args ...string) (string, error) { + name, sbArgs, cleanup, err := sandboxCommand(binary, args, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + out, err := exec.Command(name, sbArgs...).CombinedOutput() + return string(out), err + } + + out, err := sandboxed("/bin/mkdir", "-p", filepath.Join(realHome, ".local", "share", "opencode")) + require.NoError(t, err, out) + assert.DirExists(t, filepath.Join(home, ".local", "share", "opencode"), "a missing OpenCode folder and its parents can be created") + + out, err = sandboxed("/usr/bin/touch", filepath.Join(realHome, ".local", "notes.txt")) + assert.Error(t, err, "a parent folder opens for the create only") + assert.Contains(t, out, "Operation not permitted") +} From 7cced87255fc17b77ba420b6fb81c5951bd4d197 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 12:05:22 -0700 Subject: [PATCH 4/5] docs(setup): use active voice in a sandbox comment Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/sandbox.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go index 64f006a9..75d17140 100644 --- a/internal/setup/agent/sandbox.go +++ b/internal/setup/agent/sandbox.go @@ -123,8 +123,8 @@ func seatbeltProfile(p sandboxPaths) string { // Tools read the metadata of the home folder itself, for example to // resolve a path, without reading what is in it. fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) - // A cache or OpenCode folder can be missing on first use. Its missing - // parents can be created, but nothing else can be written in them. + // A cache or OpenCode folder can be missing on first use. Commands can + // create its missing parent folders, but cannot write anything else there. for _, parent := range writeParents(p) { fmt.Fprintf(&b, "(allow file-read-metadata file-write-create (literal %s))\n", sbString(parent)) } From f08f4d132e0007011e33922259779b094226a912 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 12:20:16 -0700 Subject: [PATCH 5/5] fix(setup): resolve the home folder before the install folder check installDir compared the unresolved home folder with the resolved OpenCode path. With a symlink in HOME, such as /var to /private/var, the check missed the shared ~/.local folder and opened it. The sandbox now resolves the home folder first. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/sandbox.go | 5 ++++- internal/setup/agent/sandbox_test.go | 30 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go index 75d17140..93d9aacc 100644 --- a/internal/setup/agent/sandbox.go +++ b/internal/setup/agent/sandbox.go @@ -230,6 +230,9 @@ func sandboxCommand(binary string, args []string, dir string, env []string) (str if err != nil { return "", nil, noop, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) } + // Resolve the home folder first, so installDir compares it with the + // resolved program path. + home = realPath(home) opencodeDir := "" if path, err := exec.LookPath(binary); err == nil { if real, err := filepath.EvalSymlinks(path); err == nil { @@ -238,7 +241,7 @@ func sandboxCommand(binary string, args []string, dir string, env []string) (str } // Seatbelt matches the real path, so a folder behind a symlink, such as // /var/folders on macOS, must be written as the folder it points to. - paths := buildSandboxPaths(realPath(home), realPath(dir), opencodeDir, env) + paths := buildSandboxPaths(home, realPath(dir), opencodeDir, env) paths.ReadWrite = mapPaths(paths.ReadWrite, realPath) paths.ReadOnly = mapPaths(paths.ReadOnly, realPath) profile := seatbeltProfile(paths) diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go index c3dd8931..fa7e27cc 100644 --- a/internal/setup/agent/sandbox_test.go +++ b/internal/setup/agent/sandbox_test.go @@ -148,3 +148,33 @@ func TestSandboxCommand_CreatesMissingCacheFolders(t *testing.T) { assert.Error(t, err, "a parent folder opens for the create only") assert.Contains(t, out, "Operation not permitted") } + +func TestSandboxCommand_SymlinkedHomeKeepsSharedBinFolderClosed(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + // On macOS, t.TempDir is under /var, which is a symlink to /private/var. + home := t.TempDir() + realHome, err := filepath.EvalSymlinks(home) + require.NoError(t, err) + require.NotEqual(t, home, realHome, "the test needs a home path with a symlink") + t.Setenv("HOME", home) + bin := filepath.Join(home, ".local", "bin", "opencode") + require.NoError(t, os.MkdirAll(filepath.Dir(bin), 0o700)) + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o700)) + + project := filepath.Join(home, "app") + require.NoError(t, os.MkdirAll(project, 0o700)) + + _, args, cleanup, err := sandboxCommand(bin, nil, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + profile, err := os.ReadFile(args[1]) + require.NoError(t, err) + + assert.NotContains(t, string(profile), "(subpath "+sbString(filepath.Join(realHome, ".local"))+")", "the shared ~/.local folder stays closed") + assert.Contains(t, string(profile), "(subpath "+sbString(filepath.Join(realHome, ".local", "bin"))+")") +}