diff --git a/README.md b/README.md index 9ac76dd..263892d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ For **non-cooperative** sandboxing — where the child physically cannot reach a agent-vault run --sandbox=container --share-agent-dir -- claude ``` -`--share-agent-dir` bind-mounts your host's `~/.claude` into the container so the sandboxed agent reuses your existing login. Currently Claude-only; support for other agents is coming soon. +`--share-agent-dir` bind-mounts the selected agent's host state dir into the container (`~/.claude`, `~/.cursor`, `~/.codex`, `~/.hermes`, or `~/.opencode`) so the sandboxed agent reuses your existing login. See [Container sandbox](https://docs.agent-vault.dev/guides/container-sandbox) for the threat model and flags. diff --git a/cmd/run.go b/cmd/run.go index 818d05b..73e59cc 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -73,7 +73,7 @@ Example: c.Flags().Bool("keep", false, "Don't pass --rm to docker (requires --sandbox=container)") c.Flags().Bool("no-firewall", false, "Skip iptables egress rules inside the container (requires --sandbox=container; debug only)") c.Flags().Bool("home-volume-shared", false, "Share /home/claude/.claude across invocations (requires --sandbox=container); default is a per-invocation volume, losing auth state but avoiding concurrency corruption") - c.Flags().Bool("share-agent-dir", false, "Bind-mount the host's agent state dir (~/.claude) into the container so the sandbox reuses your host login (requires --sandbox=container; mutually exclusive with --home-volume-shared)") + c.Flags().Bool("share-agent-dir", false, "Bind-mount the host's state dir for the selected agent command (for example ~/.claude, ~/.cursor, ~/.codex) into the container so the sandbox reuses your host login (requires --sandbox=container; mutually exclusive with --home-volume-shared)") return c } @@ -173,16 +173,28 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { // knownAgents maps CLI binary base-names to the (agentName, skillsDir) // pair used by maybeInstallSkills. Multiple base-names can map to the // same entry (e.g. "cursor" and "agent" both target ".cursor"). -var knownAgents = []struct { - bases []string - agentName string - baseDir string -}{ - {[]string{"claude"}, "Claude Code", ".claude"}, - {[]string{"cursor", "agent"}, "Cursor", ".cursor"}, - {[]string{"codex"}, "Codex", ".agents"}, - {[]string{"hermes"}, "Hermes", ".hermes"}, - {[]string{"opencode"}, "OpenCode", ".opencode"}, +type knownAgent struct { + bases []string + agentName string + baseDir string + stateDir string + siblingConfig string + hostSetup func(hostAgentDir string) +} + +func (a knownAgent) effectiveStateDir() string { + if a.stateDir != "" { + return a.stateDir + } + return a.baseDir +} + +var knownAgents = []knownAgent{ + {[]string{"claude"}, "Claude Code", ".claude", "", ".claude.json", populateClaudeCredentialsFromKeychain}, + {[]string{"cursor", "agent"}, "Cursor", ".cursor", "", "", nil}, + {[]string{"codex"}, "Codex", ".agents", ".codex", "", nil}, + {[]string{"hermes"}, "Hermes", ".hermes", "", "", nil}, + {[]string{"opencode"}, "OpenCode", ".opencode", "", "", nil}, } // agentSkillDir returns the display name and skills base directory for a @@ -199,6 +211,26 @@ func agentSkillDir(cmd string) (agentName, baseDir string, ok bool) { return "", "", false } +func agentContainerInfo(cmd string) (info knownAgent, ok bool) { + base := filepath.Base(cmd) + for _, a := range knownAgents { + for _, b := range a.bases { + if base == b { + return a, true + } + } + } + return info, false +} + +func knownAgentBases() []string { + var out []string + for _, a := range knownAgents { + out = append(out, a.bases...) + } + return out +} + // maybeInstallSkills installs both Agent Vault skills (CLI and HTTP) under // ~/{baseDir}/skills/ if either is missing, prompting the user once for // confirmation. agentName is used in user-facing messages (e.g. "Claude Code"). diff --git a/cmd/run_container.go b/cmd/run_container.go index cb1ede4..cab635c 100644 --- a/cmd/run_container.go +++ b/cmd/run_container.go @@ -11,6 +11,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "syscall" "time" @@ -81,8 +82,24 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st shareAgentDir, _ := cmd.Flags().GetBool("share-agent-dir") var hostAgentDir string + var hostAgentConfig string + var hostAgentSkillsDir string var hostUID, hostGID int + var containerAgentDir string + var containerConfig string + var containerAgentSkillsDir string if shareAgentDir { + if len(args) == 0 { + return errors.New("--share-agent-dir: no agent command specified") + } + agentInfo, ok := agentContainerInfo(args[0]) + if !ok { + return fmt.Errorf("--share-agent-dir: %q is not a known agent (supported: %s)", args[0], strings.Join(knownAgentBases(), ", ")) + } + image, _ := cmd.Flags().GetString("image") + if err := requireCustomImageForNonClaudeShare(agentInfo, image, args[0]); err != nil { + return err + } // Running as root on Linux would remap the in-container claude // user to uid 0, combining with --cap-add NET_ADMIN/NET_RAW/ // SETUID/SETGID/KILL to give the agent a much larger blast @@ -95,22 +112,40 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st if herr != nil { return fmt.Errorf("--share-agent-dir: resolve home dir: %w", herr) } - hostAgentDir = filepath.Join(userHome, ".claude") + effectiveStateDir := agentInfo.effectiveStateDir() + hostAgentDir = filepath.Join(userHome, effectiveStateDir) if err := os.MkdirAll(hostAgentDir, 0o700); err != nil { return fmt.Errorf("--share-agent-dir: create %s: %w", hostAgentDir, err) } - // Touch ~/.claude.json so docker doesn't auto-create a dir - // where Claude expects a file (O_CREATE without O_TRUNC is a - // no-op when the file already exists). - configPath := filepath.Join(userHome, ".claude.json") - f, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return fmt.Errorf("--share-agent-dir: ensure %s: %w", configPath, err) + if agentInfo.siblingConfig != "" { + // Touch the sibling config file so docker doesn't + // auto-create a dir where the agent expects a file. + configPath := filepath.Join(userHome, agentInfo.siblingConfig) + f, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("--share-agent-dir: ensure %s: %w", configPath, err) + } + _ = f.Close() + hostAgentConfig = configPath + } + // Agents whose skills dir (baseDir) differs from the state dir + // need a second bind mount so the agent-vault skill installed by + // maybeInstallSkills at ~//skills/ is visible inside + // the sandbox. Codex is the only such agent today (skills at + // ~/.agents/, state at ~/.codex/). + if agentInfo.baseDir != effectiveStateDir { + skillsDir := filepath.Join(userHome, agentInfo.baseDir) + if err := os.MkdirAll(skillsDir, 0o700); err != nil { + return fmt.Errorf("--share-agent-dir: create %s: %w", skillsDir, err) + } + hostAgentSkillsDir = skillsDir + containerAgentSkillsDir = sandbox.ContainerAgentHome(agentInfo.baseDir) } - _ = f.Close() - // macOS stores auth in Keychain, not on disk — bridge it into - // the file Linux Claude reads inside the sandbox. - populateClaudeCredentialsFromKeychain(hostAgentDir) + if agentInfo.hostSetup != nil { + agentInfo.hostSetup(hostAgentDir) + } + containerAgentDir = sandbox.ContainerAgentHome(effectiveStateDir) + containerConfig = sandbox.ContainerAgentConfig(agentInfo.siblingConfig) // Docker Desktop on macOS translates UIDs through its hypervisor, // so HOST_UID remapping is Linux-only. if runtime.GOOS == "linux" { @@ -215,21 +250,26 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st noFirewall, _ := cmd.Flags().GetBool("no-firewall") dockerArgs, err := sandbox.BuildRunArgs(sandbox.Config{ - ImageRef: imageRef, - SessionID: sessionID, - WorkDir: workDir, - HostCAPath: hostCAPath, - NetworkName: network.Name, - AttachTTY: term.IsTerminal(int(os.Stdin.Fd())), - Keep: keep, - NoFirewall: noFirewall, - HomeVolumeShared: homeShared, - HostAgentDir: hostAgentDir, - HostUID: hostUID, - HostGID: hostGID, - Mounts: mounts, - Env: env, - CommandArgs: args, + ImageRef: imageRef, + SessionID: sessionID, + WorkDir: workDir, + HostCAPath: hostCAPath, + NetworkName: network.Name, + AttachTTY: term.IsTerminal(int(os.Stdin.Fd())), + Keep: keep, + NoFirewall: noFirewall, + HomeVolumeShared: homeShared, + HostAgentDir: hostAgentDir, + HostAgentConfig: hostAgentConfig, + HostAgentSkillsDir: hostAgentSkillsDir, + ContainerAgentDir: containerAgentDir, + ContainerConfig: containerConfig, + ContainerAgentSkillsDir: containerAgentSkillsDir, + HostUID: hostUID, + HostGID: hostGID, + Mounts: mounts, + Env: env, + CommandArgs: args, }) if err != nil { return err @@ -291,3 +331,16 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st } return nil } + +// requireCustomImageForNonClaudeShare enforces that --share-agent-dir with +// a non-Claude agent is paired with a user-supplied --image. The bundled +// sandbox image only preinstalls @anthropic-ai/claude-code, so running +// cursor/codex/hermes/opencode on the bundled image would fail after +// docker run with "executable file not found". We surface a clearer error +// before launching the container. +func requireCustomImageForNonClaudeShare(agent knownAgent, image, cmdName string) error { + if agent.baseDir == ".claude" || image != "" { + return nil + } + return fmt.Errorf("--share-agent-dir with %q requires --image: the bundled sandbox image only preinstalls claude-code; provide your own image with %s preinstalled", cmdName, cmdName) +} diff --git a/cmd/run_container_test.go b/cmd/run_container_test.go index 6bc4f95..30377c7 100644 --- a/cmd/run_container_test.go +++ b/cmd/run_container_test.go @@ -1,13 +1,14 @@ package cmd import ( + "path/filepath" + "slices" "strings" "testing" "github.com/spf13/cobra" ) - func TestSandboxFlagsRegistered(t *testing.T) { vCmd := findSubcommand(rootCmd, "vault") if vCmd == nil { @@ -143,3 +144,103 @@ func newRunCommandForTest() *cobra.Command { c.Flags().Bool("no-mitm", false, "") return c } + +func TestAgentContainerInfo_KnownAgents(t *testing.T) { + tests := []struct { + cmd string + wantBaseDir string + wantStateDir string + wantSiblingCfg string + wantHostSetup bool + }{ + {"claude", ".claude", ".claude", ".claude.json", true}, + {"cursor", ".cursor", ".cursor", "", false}, + {"agent", ".cursor", ".cursor", "", false}, + {"codex", ".agents", ".codex", "", false}, + {"hermes", ".hermes", ".hermes", "", false}, + {"opencode", ".opencode", ".opencode", "", false}, + {"/usr/local/bin/codex", ".agents", ".codex", "", false}, + } + for _, tc := range tests { + t.Run(tc.cmd, func(t *testing.T) { + info, ok := agentContainerInfo(tc.cmd) + if !ok { + t.Fatalf("agentContainerInfo(%q): expected known agent", tc.cmd) + } + if info.baseDir != tc.wantBaseDir { + t.Errorf("baseDir = %q, want %q", info.baseDir, tc.wantBaseDir) + } + if got := info.effectiveStateDir(); got != tc.wantStateDir { + t.Errorf("effectiveStateDir() = %q, want %q", got, tc.wantStateDir) + } + if info.siblingConfig != tc.wantSiblingCfg { + t.Errorf("siblingConfig = %q, want %q", info.siblingConfig, tc.wantSiblingCfg) + } + if (info.hostSetup != nil) != tc.wantHostSetup { + t.Errorf("hostSetup != nil = %v, want %v", info.hostSetup != nil, tc.wantHostSetup) + } + }) + } +} + +func TestAgentContainerInfo_Unknown(t *testing.T) { + if _, ok := agentContainerInfo("unknown-agent"); ok { + t.Fatal("expected unknown-agent to be rejected") + } +} + +func TestRequireCustomImageForNonClaudeShare(t *testing.T) { + claude, _ := agentContainerInfo("claude") + codex, _ := agentContainerInfo("codex") + cursor, _ := agentContainerInfo("cursor") + + tests := []struct { + name string + agent knownAgent + image string + cmdName string + wantErr string + }{ + {"claude with bundled image passes", claude, "", "claude", ""}, + {"claude with custom image passes", claude, "my/image:v1", "claude", ""}, + {"codex with bundled image is rejected", codex, "", "codex", "--image"}, + {"codex with custom image passes", codex, "my/codex:v1", "codex", ""}, + {"cursor with bundled image is rejected", cursor, "", "cursor", "--image"}, + {"cursor with custom image passes", cursor, "my/cursor:v1", "cursor", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requireCustomImageForNonClaudeShare(tc.agent, tc.image, tc.cmdName) + if tc.wantErr == "" { + if err != nil { + t.Errorf("expected nil, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("err = %v, want substring %q", err, tc.wantErr) + } + if err != nil && !strings.Contains(err.Error(), tc.cmdName) { + t.Errorf("err = %v, want substring %q (the command name)", err, tc.cmdName) + } + }) + } +} + +func TestKnownAgentBases(t *testing.T) { + got := knownAgentBases() + want := []string{"claude", "cursor", "agent", "codex", "hermes", "opencode"} + if len(got) != len(want) { + t.Fatalf("len(knownAgentBases) = %d, want %d (%v)", len(got), len(want), got) + } + for _, base := range want { + if !slices.Contains(got, base) { + t.Errorf("knownAgentBases missing %q in %v", base, got) + } + } + for _, base := range got { + if strings.Contains(base, string(filepath.Separator)) { + t.Errorf("knownAgentBases entry %q must be a base command", base) + } + } +} diff --git a/docs/guides/container-sandbox.mdx b/docs/guides/container-sandbox.mdx index a67f1de..3cd7505 100644 --- a/docs/guides/container-sandbox.mdx +++ b/docs/guides/container-sandbox.mdx @@ -29,7 +29,7 @@ First run takes ~60s while the sandbox image builds. Subsequent runs reuse the c - **Mounts**: - `$PWD → /workspace` (read-write, your project) - `~/.agent-vault/sandbox/ca-.pem → /etc/agent-vault/ca.pem` (read-only, MITM CA) - - `agent-vault-claude-home- → /home/claude/.claude` (per-invocation by default; removed after the container exits. See `--home-volume-shared` for a persistent docker volume, or `--share-agent-dir` to bind-mount your host `~/.claude` instead.) + - `agent-vault-claude-home- → /home/claude/.claude` (per-invocation by default; removed after the container exits. See `--home-volume-shared` for a persistent docker volume, or `--share-agent-dir` to bind-mount your host agent dir instead.) ## Flags @@ -41,7 +41,7 @@ First run takes ~60s while the sandbox image builds. Subsequent runs reuse the c | `--keep` | `false` | Omit `--rm` so the container is available for `docker inspect` / `docker logs` after exit. | | `--no-firewall` | `false` | **Debug only.** Skip `init-firewall.sh`; container has unrestricted egress. Prints a loud warning. | | `--home-volume-shared` | `false` | Share `/home/claude/.claude` across invocations via a persistent docker volume. Default is per-invocation (auth doesn't persist, but concurrent runs can't corrupt each other). | -| `--share-agent-dir` | `false` | Bind-mount the host's agent state dir (`~/.claude`) into the container so the sandbox reuses your host login (auth, project history, MCP config). Mutually exclusive with `--home-volume-shared`. | +| `--share-agent-dir` | `false` | Bind-mount the host state dir for the selected command (`~/.claude`, `~/.cursor`, `~/.codex`, `~/.hermes`, `~/.opencode`) into the container so the sandbox reuses your host login (auth, project history, MCP config). Mutually exclusive with `--home-volume-shared`. | ## Bundled image vs `--image` @@ -70,14 +70,27 @@ agent-vault vault run --sandbox=container --image=my-org/my-agent-sandbox:v1 -- ``` Don't run two `--home-volume-shared` sessions concurrently — there's no locking, and the volume's contents will race. -3. **Bind-mount the host's `~/.claude` (`--share-agent-dir`).** Reuses your existing host login — starting the sandbox feels identical to running `claude` on the host (no onboarding, your project history is there). Mutually exclusive with `--home-volume-shared`. +3. **Bind-mount the host agent dir (`--share-agent-dir`).** Reuses your existing host login for the selected command. Mutually exclusive with `--home-volume-shared`. ```bash agent-vault vault run --sandbox=container --share-agent-dir -- claude ``` + The bind source and destination depend on the command after `--`: + + | Command | Host dir | Container path | Extra setup | + |---------|----------|----------------|-------------| + | `claude` | `~/.claude` | `/home/claude/.claude` | Also bind `~/.claude.json` to `/home/claude/.claude.json`; macOS Keychain bridge writes `~/.claude/.credentials.json` when needed | + | `cursor` / `agent` | `~/.cursor` | `/home/claude/.cursor` | None | + | `codex` | `~/.codex` | `/home/claude/.codex` | Also bind `~/.agents` to `/home/claude/.agents` so the Agent Vault skill installed at `~/.agents/skills/agent-vault/SKILL.md` is visible inside the sandbox | + | `hermes` | `~/.hermes` | `/home/claude/.hermes` | None | + | `opencode` | `~/.opencode` | `/home/claude/.opencode` | None | + + Unknown commands are rejected with a list of supported agent commands. Running a non-Claude agent with `--share-agent-dir` also requires `--image` with that agent preinstalled — the bundled sandbox image only has `@anthropic-ai/claude-code` on it, so agent-vault rejects `--share-agent-dir -- cursor` (etc.) on the bundled image before launching the container. Concurrency matches the status quo of running `claude` directly on the host — session files are UUID-keyed, so two concurrent sandboxes behave the same as two host-level Claude sessions. On Linux, agent-vault passes `HOST_UID`/`HOST_GID` so the container's `claude` user matches your host uid — writes to the bind mount land owned by you, not the baked-in container uid. **macOS auth bridge.** Claude stores its OAuth credential in the macOS Keychain, not on disk, so the bind mount alone doesn't carry the login across. When `--share-agent-dir` is set on a Mac host, agent-vault calls `security find-generic-password -s "Claude Code-credentials" -w` once to extract the credential and writes it to `~/.claude/.credentials.json` (mode 0600) — the path Linux Claude reads. Only runs if the file is absent; the container (or you) can refresh it thereafter. First use may trigger a Keychain confirmation prompt. Trade-off: the credential now exists as a filesystem-readable file on your Mac, slightly weaker than Keychain-only storage. If you'd rather avoid that, skip `--share-agent-dir` (or `rm ~/.claude/.credentials.json` and `/login` inside the sandbox — Claude will write a fresh file itself). + The bundled image still preinstalls only `@anthropic-ai/claude-code`. For `cursor`, `codex`, `hermes`, or `opencode`, provide `--image` with that agent preinstalled. + Security note: the sandbox protects *vault-brokered* credentials (Stripe, GitHub, etc.) by forcing egress through Agent Vault. Your Claude login in `~/.claude` is the agent's own identity — sharing it with the sandboxed agent is strictly a UX choice, not a credential exposure, since the agent already needs that identity to function. The genuinely sensitive `~/.agent-vault/` directory stays off-limits in all three modes. ## Threat model diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 8711f73..9adf2af 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -259,7 +259,7 @@ description: "Complete reference for all Agent Vault CLI commands." | `--keep` | `false` | Omit `--rm` from `docker run` (`--sandbox=container` only; useful for debugging). | | `--no-firewall` | `false` | Skip the iptables egress lockdown (`--sandbox=container` only; debug, prints a warning). | | `--home-volume-shared` | `false` | Share `/home/claude/.claude` across invocations via a persistent docker volume (`--sandbox=container` only). Default is a per-invocation volume — auth doesn't persist but concurrent runs can't corrupt each other. | - | `--share-agent-dir` | `false` | Bind-mount the host's agent state dir (`~/.claude`) at `/home/claude/.claude` so the sandbox reuses your host login (`--sandbox=container` only). On Linux the container's `claude` user is remapped to your host uid/gid so writes land owned by you. Mutually exclusive with `--home-volume-shared`. | + | `--share-agent-dir` | `false` | Bind-mount the host state dir for the selected command (`~/.claude`, `~/.cursor`, `~/.codex`, `~/.hermes`, `~/.opencode`) into `/home/claude/` so the sandbox reuses your host login (`--sandbox=container` only). On Linux the container's `claude` user is remapped to your host uid/gid so writes land owned by you. Mutually exclusive with `--home-volume-shared`. | diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go index 73456dc..fa497e8 100644 --- a/internal/sandbox/docker.go +++ b/internal/sandbox/docker.go @@ -12,21 +12,26 @@ import ( // resolved `docker run` argv. All values are already decided (mode, // session ID, network, TTY) — this type does no I/O. type Config struct { - ImageRef string // "agent-vault/sandbox:" or user --image - SessionID string // 16 hex chars; names the network and per-invocation volume - WorkDir string // host path bound at /workspace - HostCAPath string // host path bound read-only at ContainerCAPath - NetworkName string // "agent-vault-" — must not be empty - AttachTTY bool // true if stdin is a TTY; adds -t - Keep bool // true → omit --rm - NoFirewall bool // true → container skips init-firewall.sh (debug only) - HomeVolumeShared bool // true → shared volume, false → per-invocation - HostAgentDir string // non-empty → bind-mount this host path at ContainerClaudeHome instead of a docker volume; a sibling .claude.json (if present) is bind-mounted too - HostUID int // >0 → pass HOST_UID/HOST_GID env so entrypoint.sh can remap the claude user (linux only) - HostGID int - Mounts []string // raw --mount "src:dst[:ro]" strings - Env []string // from BuildContainerEnv - CommandArgs []string // claude + any agent args + ImageRef string // "agent-vault/sandbox:" or user --image + SessionID string // 16 hex chars; names the network and per-invocation volume + WorkDir string // host path bound at /workspace + HostCAPath string // host path bound read-only at ContainerCAPath + NetworkName string // "agent-vault-" — must not be empty + AttachTTY bool // true if stdin is a TTY; adds -t + Keep bool // true → omit --rm + NoFirewall bool // true → container skips init-firewall.sh (debug only) + HomeVolumeShared bool // true → shared volume, false → per-invocation + HostAgentDir string // non-empty → bind-mount this host path at ContainerAgentDir instead of a docker volume + HostAgentConfig string // optional sibling config file path on host; empty means no sibling bind + HostAgentSkillsDir string // optional secondary bind for agents whose skills dir (~/.agents for Codex) differs from the state dir; empty means no extra bind + ContainerAgentDir string // container mount target for HostAgentDir; empty falls back to ContainerClaudeHome + ContainerConfig string // optional container mount target for HostAgentConfig; empty means no sibling bind + ContainerAgentSkillsDir string // container mount target for HostAgentSkillsDir; must be set iff HostAgentSkillsDir is set + HostUID int // >0 → pass HOST_UID/HOST_GID env so entrypoint.sh can remap the claude user (linux only) + HostGID int + Mounts []string // raw --mount "src:dst[:ro]" strings + Env []string // from BuildContainerEnv + CommandArgs []string // claude + any agent args } type parsedMount struct { @@ -94,6 +99,20 @@ func BuildRunArgs(cfg Config) ([]string, error) { if err != nil { return nil, err } + if cfg.ContainerAgentDir != "" && cfg.ContainerAgentDir != ContainerClaudeHome { + if pm.Dst == cfg.ContainerAgentDir || + strings.HasPrefix(pm.Dst, cfg.ContainerAgentDir+"/") || + strings.HasPrefix(cfg.ContainerAgentDir, pm.Dst+"/") { + return nil, fmt.Errorf("--mount: refusing to override reserved container path %s", cfg.ContainerAgentDir) + } + } + if cfg.ContainerAgentSkillsDir != "" { + if pm.Dst == cfg.ContainerAgentSkillsDir || + strings.HasPrefix(pm.Dst, cfg.ContainerAgentSkillsDir+"/") || + strings.HasPrefix(cfg.ContainerAgentSkillsDir, pm.Dst+"/") { + return nil, fmt.Errorf("--mount: refusing to override reserved container path %s", cfg.ContainerAgentSkillsDir) + } + } parsed = append(parsed, pm) } @@ -145,6 +164,11 @@ func BuildRunArgs(cfg Config) ([]string, error) { args = append(args, "-v", cfg.WorkDir+":/workspace") args = append(args, "-v", cfg.HostCAPath+":"+ContainerCAPath+":ro") + containerAgentDir := cfg.ContainerAgentDir + if containerAgentDir == "" { + containerAgentDir = ContainerClaudeHome + } + if cfg.HostAgentDir != "" { resolvedAgentDir, err := filepath.EvalSymlinks(cfg.HostAgentDir) if err != nil { @@ -156,24 +180,40 @@ func BuildRunArgs(cfg Config) ([]string, error) { if err := validateHostSrc(resolvedAgentDir, home); err != nil { return nil, fmt.Errorf("HostAgentDir: %w", err) } - args = append(args, "-v", resolvedAgentDir+":"+ContainerClaudeHome) + args = append(args, "-v", resolvedAgentDir+":"+containerAgentDir) - // Claude reads ~/.claude.json as a sibling file to the dir. - // Bind it if present; bail-if-absent keeps docker from - // auto-creating a directory where Claude expects a file. - configPath := filepath.Join(filepath.Dir(resolvedAgentDir), ".claude.json") - if resolvedConfig, err := filepath.EvalSymlinks(configPath); err == nil { + if cfg.HostAgentConfig != "" && cfg.ContainerConfig != "" { + resolvedConfig, err := filepath.EvalSymlinks(cfg.HostAgentConfig) + if err != nil { + return nil, fmt.Errorf("resolving HostAgentConfig: %w", err) + } if err := validateHostSrc(resolvedConfig, home); err != nil { return nil, fmt.Errorf("HostAgentConfig: %w", err) } - args = append(args, "-v", resolvedConfig+":"+ContainerClaudeConfig) + args = append(args, "-v", resolvedConfig+":"+cfg.ContainerConfig) + } + + if cfg.HostAgentSkillsDir != "" && cfg.ContainerAgentSkillsDir != "" { + // Second bind for agents whose skills dir (baseDir) differs + // from the state dir — Codex stores auth under ~/.codex but + // agent-vault installs its skill at ~/.agents/skills/. Both + // paths need to be available inside the container so the + // agent finds its login AND the agent-vault skill. + resolvedSkills, err := filepath.EvalSymlinks(cfg.HostAgentSkillsDir) + if err != nil { + return nil, fmt.Errorf("resolving HostAgentSkillsDir: %w", err) + } + if err := validateHostSrc(resolvedSkills, home); err != nil { + return nil, fmt.Errorf("HostAgentSkillsDir: %w", err) + } + args = append(args, "-v", resolvedSkills+":"+cfg.ContainerAgentSkillsDir) } } else { homeVolume := "agent-vault-claude-home-" + cfg.SessionID if cfg.HomeVolumeShared { homeVolume = "agent-vault-claude-home" } - args = append(args, "-v", homeVolume+":"+ContainerClaudeHome) + args = append(args, "-v", homeVolume+":"+containerAgentDir) } for _, m := range parsed { diff --git a/internal/sandbox/docker_test.go b/internal/sandbox/docker_test.go index a2eba52..8c7e162 100644 --- a/internal/sandbox/docker_test.go +++ b/internal/sandbox/docker_test.go @@ -150,6 +150,9 @@ func TestBuildRunArgs_HostAgentDirBindMount(t *testing.T) { t.Fatalf("create config: %v", err) } _ = f.Close() + cfg.HostAgentConfig = agentConfig + cfg.ContainerAgentDir = ContainerAgentHome(".claude") + cfg.ContainerConfig = ContainerAgentConfig(".claude.json") cfg.HostUID = 501 cfg.HostGID = 20 @@ -159,11 +162,11 @@ func TestBuildRunArgs_HostAgentDirBindMount(t *testing.T) { } resolvedDir, _ := filepath.EvalSymlinks(agentDir) - if !hasFlagValue(args, "-v", resolvedDir+":"+ContainerClaudeHome) { + if !hasFlagValue(args, "-v", resolvedDir+":"+ContainerAgentHome(".claude")) { t.Errorf("expected host-agent-dir bind in args, got %v", args) } resolvedCfg, _ := filepath.EvalSymlinks(agentConfig) - if !hasFlagValue(args, "-v", resolvedCfg+":"+ContainerClaudeConfig) { + if !hasFlagValue(args, "-v", resolvedCfg+":"+ContainerAgentConfig(".claude.json")) { t.Errorf("expected host-agent-config bind in args, got %v", args) } for _, a := range args { @@ -180,11 +183,11 @@ func TestBuildRunArgs_HostAgentDirBindMount(t *testing.T) { } func TestBuildRunArgs_HostAgentDirSkipsAbsentConfig(t *testing.T) { - // If the sibling .claude.json doesn't exist, BuildRunArgs must - // omit the config bind entirely — otherwise docker would - // auto-create a directory where Claude expects a file. + // If HostAgentConfig isn't set, BuildRunArgs must omit the config + // bind entirely. cfg := baseConfig(t) cfg.HostAgentDir = t.TempDir() + cfg.ContainerAgentDir = ContainerAgentHome(".cursor") args, err := BuildRunArgs(cfg) if err != nil { @@ -192,7 +195,27 @@ func TestBuildRunArgs_HostAgentDirSkipsAbsentConfig(t *testing.T) { } for _, a := range args { if strings.HasSuffix(a, ":"+ContainerClaudeConfig) { - t.Errorf("expected no config bind when sibling .claude.json absent; got %q", a) + t.Errorf("expected no config bind when HostAgentConfig is empty; got %q", a) + } + } +} + +func TestBuildRunArgs_HostAgentDirCursorBindMount(t *testing.T) { + cfg := baseConfig(t) + cfg.HostAgentDir = t.TempDir() + cfg.ContainerAgentDir = ContainerAgentHome(".cursor") + + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + resolvedDir, _ := filepath.EvalSymlinks(cfg.HostAgentDir) + if !hasFlagValue(args, "-v", resolvedDir+":"+ContainerAgentHome(".cursor")) { + t.Fatalf("expected cursor host-agent-dir bind in args, got %v", args) + } + for _, a := range args { + if strings.HasSuffix(a, ":"+ContainerClaudeConfig) { + t.Fatalf("did not expect sibling config bind for cursor, got %q", a) } } } @@ -353,6 +376,59 @@ func TestParseAndValidateMount_RejectReservedContainerDst(t *testing.T) { } } +func TestBuildRunArgs_RejectUserMountActiveAgentDir(t *testing.T) { + cfg := baseConfig(t) + cfg.ContainerAgentDir = ContainerAgentHome(".cursor") + cfg.Mounts = []string{cfg.WorkDir + ":" + ContainerAgentHome(".cursor")} + _, err := BuildRunArgs(cfg) + if err == nil { + t.Fatal("expected reserved-path rejection for active ContainerAgentDir") + } + if !strings.Contains(err.Error(), ContainerAgentHome(".cursor")) { + t.Errorf("err = %q, want to mention %q", err.Error(), ContainerAgentHome(".cursor")) + } +} + +func TestBuildRunArgs_HostAgentSkillsDirBindMount(t *testing.T) { + // Codex is the canonical case: skills at ~/.agents (baseDir), + // state at ~/.codex (effective state dir). Both must be mounted + // so the agent-vault skill installed by maybeInstallSkills is + // visible inside the sandbox alongside the state dir. + cfg := baseConfig(t) + cfg.HostAgentDir = t.TempDir() + cfg.ContainerAgentDir = ContainerAgentHome(".codex") + cfg.HostAgentSkillsDir = t.TempDir() + cfg.ContainerAgentSkillsDir = ContainerAgentHome(".agents") + + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + resolvedState, _ := filepath.EvalSymlinks(cfg.HostAgentDir) + if !hasFlagValue(args, "-v", resolvedState+":"+ContainerAgentHome(".codex")) { + t.Errorf("expected state-dir bind %q:%q in args, got %v", + resolvedState, ContainerAgentHome(".codex"), args) + } + resolvedSkills, _ := filepath.EvalSymlinks(cfg.HostAgentSkillsDir) + if !hasFlagValue(args, "-v", resolvedSkills+":"+ContainerAgentHome(".agents")) { + t.Errorf("expected skills-dir bind %q:%q in args, got %v", + resolvedSkills, ContainerAgentHome(".agents"), args) + } +} + +func TestBuildRunArgs_RejectUserMountActiveSkillsDir(t *testing.T) { + cfg := baseConfig(t) + cfg.ContainerAgentSkillsDir = ContainerAgentHome(".agents") + cfg.Mounts = []string{cfg.WorkDir + ":" + ContainerAgentHome(".agents")} + _, err := BuildRunArgs(cfg) + if err == nil { + t.Fatal("expected reserved-path rejection for active ContainerAgentSkillsDir") + } + if !strings.Contains(err.Error(), ContainerAgentHome(".agents")) { + t.Errorf("err = %q, want to mention %q", err.Error(), ContainerAgentHome(".agents")) + } +} + func TestParseAndValidateMount_RejectDockerSocket(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("docker socket path is POSIX-specific") diff --git a/internal/sandbox/env.go b/internal/sandbox/env.go index f2b6d41..2f45ed8 100644 --- a/internal/sandbox/env.go +++ b/internal/sandbox/env.go @@ -14,6 +14,22 @@ const ( ContainerClaudeConfig = "/home/claude/.claude.json" ) +// ContainerAgentHome returns the container path where --share-agent-dir +// bind-mounts the host agent directory. The "claude" segment is the Unix +// user baked into the sandbox image. +func ContainerAgentHome(baseDir string) string { + return "/home/claude/" + baseDir +} + +// ContainerAgentConfig returns the sibling config file path for agents +// that use one. Empty siblingConfig means no sibling config bind. +func ContainerAgentConfig(siblingConfig string) string { + if siblingConfig == "" { + return "" + } + return "/home/claude/" + siblingConfig +} + // ProxyEnvParams feeds BuildProxyEnv. Process mode and container mode // differ only in Host (loopback vs host.docker.internal) and CAPath // (host-local vs container-local bind mount).