Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 43 additions & 11 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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").
Expand Down
107 changes: 80 additions & 27 deletions cmd/run_container.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -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
Expand All @@ -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 ~/<baseDir>/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" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
103 changes: 102 additions & 1 deletion cmd/run_container_test.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
19 changes: 16 additions & 3 deletions docs/guides/container-sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-<session>.pem → /etc/agent-vault/ca.pem` (read-only, MITM CA)
- `agent-vault-claude-home-<session> → /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-<session> → /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

Expand All @@ -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`

Expand Down Expand Up @@ -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
Expand Down
Loading