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..93d9aacc --- /dev/null +++ b/internal/setup/agent/sandbox.go @@ -0,0 +1,260 @@ +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", + // 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 +// 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) + } + } + // 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) + } + 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)) + // 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)) + } + 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() +} + +// 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) + `"` +} + +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) + } + // 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 { + 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(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..fa7e27cc --- /dev/null +++ b/internal/setup/agent/sandbox_test.go @@ -0,0 +1,180 @@ +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 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")) + 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") +} + +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") +} + +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"))+")") +} diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index fcb760ad..f635a8f2 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -46,16 +46,22 @@ 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, // Set the user name too. A shell value then cannot make OpenCode // refuse the client's basic auth. "OPENCODE_SERVER_USERNAME": serverUsername, "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 { @@ -71,6 +77,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{ @@ -86,6 +93,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve } go func() { _ = cmd.Wait() + cleanup() close(s.exited) }()