diff --git a/.env.example b/.env.example index b95f53d..77205f7 100644 --- a/.env.example +++ b/.env.example @@ -37,9 +37,9 @@ # Development (optional) # AGENT_VAULT_DEV_MODE=false # when true, allows internal/localhost hosts in proposals -# Sandbox mode for `agent-vault vault run` (optional) -# process (default, cooperative) | container (non-cooperative Docker sandbox with iptables egress lock) -# AGENT_VAULT_SANDBOX=process +# Isolation mode for `agent-vault vault run` (optional) +# host (default, cooperative — runs on the host with HTTPS_PROXY) | container (non-cooperative Docker container with iptables egress lock) +# AGENT_VAULT_ISOLATION=host # Observability (optional) # AGENT_VAULT_LOG_LEVEL=info # info (default) | debug — debug emits one line per proxied request (no secret values) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba89ee4..ba7b735 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,7 +37,7 @@ updates: semver-patch-days: 5 - package-ecosystem: docker - directory: /internal/sandbox/assets + directory: /internal/isolation/assets schedule: interval: weekly commit-message: diff --git a/CLAUDE.md b/CLAUDE.md index c5b5261..731a346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ make docker # Multi-stage Docker image; data persisted at /data/.agent-vau - Vault role: `proxy` < `member` < `admin`. Proxy can use the proxy and raise proposals; member can manage credentials/services; admin can invite humans. - **KEK/DEK key wrapping**: A random DEK (Data Encryption Key) encrypts credentials and the CA key at rest (AES-256-GCM). If a master password is set, Argon2id derives a KEK (Key Encryption Key) that wraps the DEK; changing the password re-wraps the DEK without re-encrypting credentials. If no password is set (passwordless mode), the DEK is stored in plaintext — suitable for PaaS deploys where volume security is the trust boundary. Login uses email+password or Google OAuth. The first user to register becomes the instance owner and is auto-granted vault admin on `default`. - **Agent skills are the agent-facing contract.** [cmd/skill_cli.md](cmd/skill_cli.md) and [cmd/skill_http.md](cmd/skill_http.md) are embedded into the binary, installed by `vault run`, and served publicly at `/v1/skills/{cli,http}`. They are the authoritative reference for what agents can do. -- **Two sandbox modes for `vault run`** (selected via `--sandbox` or `AGENT_VAULT_SANDBOX`): `process` (default, cooperative — fork+exec with `HTTPS_PROXY` envvars) and `container` (non-cooperative — Docker container with iptables egress locked to the Agent Vault proxy). Container mode lives in [internal/sandbox/](internal/sandbox/) with an embedded Dockerfile + init-firewall.sh + entrypoint.sh, built on first use and cached by content hash. +- **Two isolation modes for `vault run`** (selected via `--isolation` or `AGENT_VAULT_ISOLATION`): `host` (default, cooperative — fork+exec on the host with `HTTPS_PROXY` envvars) and `container` (non-cooperative — Docker container with iptables egress locked to the Agent Vault proxy). Container mode lives in [internal/isolation/](internal/isolation/) with an embedded Dockerfile + init-firewall.sh + entrypoint.sh, built on first use and cached by content hash. ## Where to look for details diff --git a/README.md b/README.md index f7e6571..d556047 100644 --- a/README.md +++ b/README.md @@ -88,15 +88,15 @@ agent-vault vault run -- opencode The agent calls APIs normally (e.g. `fetch("https://api.github.com/...")`). Agent Vault intercepts the request, injects the credential, and forwards it upstream. The agent never sees secrets. -For **non-cooperative** sandboxing — where the child physically cannot reach anything except the Agent Vault proxy, regardless of what it tries — launch it in a Docker container with egress locked down by iptables: +For **non-cooperative** isolation — where the child physically cannot reach anything except the Agent Vault proxy, regardless of what it tries — launch it in a Docker container with egress locked down by iptables: ```bash -agent-vault run --sandbox=container --share-agent-dir -- claude +agent-vault run --isolation=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 your host's `~/.claude` into the container so the agent reuses your existing login. Currently Claude-only; support for other agents is coming soon. -See [Container sandbox](https://docs.agent-vault.dev/guides/container-sandbox) for the threat model and flags. +See [Container isolation](https://docs.agent-vault.dev/guides/container-isolation) for the threat model and flags. ### SDK — sandboxed agents (Docker, Daytona, E2B) diff --git a/cmd/claude_credentials.go b/cmd/claude_credentials.go index ef9226b..cacf30a 100644 --- a/cmd/claude_credentials.go +++ b/cmd/claude_credentials.go @@ -15,7 +15,7 @@ const keychainItemName = "Claude Code-credentials" // populateClaudeCredentialsFromKeychain extracts the host's Claude Code // credential from the macOS Keychain and writes it to the host's -// ~/.claude/.credentials.json so the sandbox (Linux, file-based auth) +// ~/.claude/.credentials.json so the container (Linux, file-based auth) // picks it up through the --share-agent-dir bind mount. // // Only runs on macOS hosts — other OSes already store auth in the file diff --git a/cmd/exit_code_error.go b/cmd/exit_code_error.go index 1c05188..b570346 100644 --- a/cmd/exit_code_error.go +++ b/cmd/exit_code_error.go @@ -4,7 +4,7 @@ import "fmt" // ExitCodeError carries a specific process exit code through the Cobra // RunE return path. Execute() unwraps it and calls os.Exit(Code) so -// wrapped subprocesses (e.g. the sandbox container) can propagate their +// wrapped subprocesses (e.g. the isolation container) can propagate their // real status to the shell without losing deferred cleanups — returning // the error lets defers inside the command body run before the process // exits. diff --git a/cmd/isolation_flag.go b/cmd/isolation_flag.go new file mode 100644 index 0000000..b509d32 --- /dev/null +++ b/cmd/isolation_flag.go @@ -0,0 +1,30 @@ +package cmd + +import "fmt" + +// IsolationMode selects how `vault run` runs the child agent. +type IsolationMode string + +const ( + IsolationHost IsolationMode = "host" + IsolationContainer IsolationMode = "container" +) + +func (m *IsolationMode) String() string { + if *m == "" { + return string(IsolationHost) + } + return string(*m) +} + +func (m *IsolationMode) Set(v string) error { + switch IsolationMode(v) { + case IsolationHost, IsolationContainer: + *m = IsolationMode(v) + return nil + default: + return fmt.Errorf("must be one of: %s, %s", IsolationHost, IsolationContainer) + } +} + +func (*IsolationMode) Type() string { return "string" } diff --git a/cmd/run.go b/cmd/run.go index 407e59d..76052e5 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -14,7 +14,7 @@ import ( "strings" "syscall" - "github.com/Infisical/agent-vault/internal/sandbox" + "github.com/Infisical/agent-vault/internal/isolation" "github.com/Infisical/agent-vault/internal/session" "github.com/Infisical/agent-vault/internal/store" "github.com/charmbracelet/huh" @@ -61,20 +61,20 @@ Example: RunE: runCmdRunE, } - var sbx SandboxMode - c.Flags().Var(&sbx, "sandbox", "Sandbox mode: process (default) or container") + var iso IsolationMode + c.Flags().Var(&iso, "isolation", "Isolation mode: host (default) or container") c.Flags().String("address", "", "Agent Vault server address (defaults to session address)") c.Flags().String("role", "", "Vault role for the agent session (proxy, member, admin; default: proxy)") c.Flags().Int("ttl", 0, "Session TTL in seconds (300–604800; default: server default 24h)") c.Flags().Bool("no-mitm", false, "Skip HTTPS_PROXY/CA env injection for the child (explicit /proxy only)") - c.Flags().String("image", "", "Container image override (requires --sandbox=container)") - c.Flags().StringArray("mount", nil, "Extra bind mount src:dst[:ro] (repeatable; requires --sandbox=container)") - 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().String("image", "", "Container image override (requires --isolation=container)") + c.Flags().StringArray("mount", nil, "Extra bind mount src:dst[:ro] (repeatable; requires --isolation=container)") + c.Flags().Bool("keep", false, "Don't pass --rm to docker (requires --isolation=container)") + c.Flags().Bool("no-firewall", false, "Skip iptables egress rules inside the container (requires --isolation=container; debug only)") + c.Flags().Bool("home-volume-shared", false, "Share /home/claude/.claude across invocations (requires --isolation=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 it reuses your host login (requires --isolation=container; mutually exclusive with --home-volume-shared)") return c } @@ -83,21 +83,21 @@ var runCmd = newRunCmd("agent-vault vault run") var topRunCmd = newRunCmd("agent-vault run") func runCmdRunE(cmd *cobra.Command, args []string) error { - // 0. Resolve sandbox mode and validate flag compatibility before any + // 0. Resolve isolation mode and validate flag compatibility before any // network I/O — the user sees conflicts immediately, not after // a slow session-mint round-trip. - mode := *cmd.Flags().Lookup("sandbox").Value.(*SandboxMode) + mode := *cmd.Flags().Lookup("isolation").Value.(*IsolationMode) if mode == "" { - if v := os.Getenv("AGENT_VAULT_SANDBOX"); v != "" { + if v := os.Getenv("AGENT_VAULT_ISOLATION"); v != "" { if err := mode.Set(v); err != nil { - return fmt.Errorf("AGENT_VAULT_SANDBOX: %w", err) + return fmt.Errorf("AGENT_VAULT_ISOLATION: %w", err) } } } if mode == "" { - mode = SandboxProcess + mode = IsolationHost } - if err := validateSandboxFlagConflicts(cmd, mode); err != nil { + if err := validateIsolationFlagConflicts(cmd, mode); err != nil { return err } @@ -121,7 +121,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { return err } - if mode == SandboxContainer { + if mode == IsolationContainer { return runContainer(cmd, args, scopedToken, addr, vault) } @@ -349,8 +349,8 @@ func fetchUserVaults(addr, token string) ([]string, error) { // corporate HTTPS_PROXY from the parent shell would otherwise silently // win and the MITM route would be bypassed entirely. var mitmInjectedKeys = func() map[string]struct{} { - m := make(map[string]struct{}, len(sandbox.ProxyEnvKeys)) - for _, k := range sandbox.ProxyEnvKeys { + m := make(map[string]struct{}, len(isolation.ProxyEnvKeys)) + for _, k := range isolation.ProxyEnvKeys { m[k] = struct{}{} } return m @@ -423,7 +423,7 @@ func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]stri } env = stripEnvKeys(env, mitmInjectedKeys) - env = append(env, sandbox.BuildProxyEnv(sandbox.ProxyEnvParams{ + env = append(env, isolation.BuildProxyEnv(isolation.ProxyEnvParams{ Host: mitmHost, Port: port, Token: token, diff --git a/cmd/run_container.go b/cmd/run_container.go index cb1ede4..df51b78 100644 --- a/cmd/run_container.go +++ b/cmd/run_container.go @@ -17,21 +17,21 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" - "github.com/Infisical/agent-vault/internal/sandbox" + "github.com/Infisical/agent-vault/internal/isolation" ) -// containerOnlyFlags are no-ops in process mode. processOnlyFlags are +// containerOnlyFlags are no-ops in host mode. hostOnlyFlags are // no-ops in container mode (where MITM is always on, enforced by the // iptables lockdown). Either direction is a foot-gun if accepted // silently — reject in both. var ( containerOnlyFlags = []string{"image", "mount", "keep", "no-firewall", "home-volume-shared", "share-agent-dir"} - processOnlyFlags = []string{"no-mitm"} + hostOnlyFlags = []string{"no-mitm"} ) // validateContainerFlagCombos enforces mutual-exclusion between container-mode // flags that would otherwise both try to own /home/claude/.claude. Split from -// validateSandboxFlagConflicts because the "which mode wants which flag" +// validateIsolationFlagConflicts because the "which mode wants which flag" // axis and the "these two flags can't coexist" axis are independent. func validateContainerFlagCombos(cmd *cobra.Command) error { homeShared, _ := cmd.Flags().GetBool("home-volume-shared") @@ -42,12 +42,12 @@ func validateContainerFlagCombos(cmd *cobra.Command) error { return nil } -func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error { +func validateIsolationFlagConflicts(cmd *cobra.Command, mode IsolationMode) error { var disallowed []string var otherMode string - if mode == SandboxContainer { - disallowed = processOnlyFlags - otherMode = "process" + if mode == IsolationContainer { + disallowed = hostOnlyFlags + otherMode = "host" } else { disallowed = containerOnlyFlags otherMode = "container" @@ -57,7 +57,7 @@ func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error { if f == nil || !f.Changed { continue } - return fmt.Errorf("--%s requires --sandbox=%s", name, otherMode) + return fmt.Errorf("--%s requires --isolation=%s", name, otherMode) } return nil } @@ -66,10 +66,10 @@ func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error { // egress locked to the agent-vault proxy via iptables. func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault string) error { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - return fmt.Errorf("--sandbox=container: only linux and darwin are supported in v1 (got %s)", runtime.GOOS) + return fmt.Errorf("--isolation=container: only linux and darwin are supported in v1 (got %s)", runtime.GOOS) } if _, err := exec.LookPath("docker"); err != nil { - return errors.New("--sandbox=container: `docker` not found in PATH") + return errors.New("--isolation=container: `docker` not found in PATH") } // Validate flag combos + set up host-side state for --share-agent-dir @@ -109,7 +109,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st } _ = f.Close() // macOS stores auth in Keychain, not on disk — bridge it into - // the file Linux Claude reads inside the sandbox. + // the file Linux Claude reads inside the container. populateClaudeCredentialsFromKeychain(hostAgentDir) // Docker Desktop on macOS translates UIDs through its hypervisor, // so HOST_UID remapping is Linux-only. @@ -126,18 +126,18 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st // Housekeeping: trim resources leaked by crashed runs before we // create new ones. All best-effort. - sandbox.PruneHostCAFiles() - _ = sandbox.PruneStaleNetworks(ctx, sandbox.DefaultPruneGrace) - _ = sandbox.PruneStaleVolumes(ctx) + isolation.PruneHostCAFiles() + _ = isolation.PruneStaleNetworks(ctx, isolation.DefaultPruneGrace) + _ = isolation.PruneStaleVolumes(ctx) // Pull the MITM CA from the server. Container mode always routes - // through MITM — --no-mitm is a process-mode-only escape hatch. + // through MITM — --no-mitm is a host-mode-only escape hatch. pem, mitmPort, mitmEnabled, mitmTLS, err := fetchMITMCA(addr) if err != nil { return fmt.Errorf("fetch MITM CA: %w", err) } if !mitmEnabled { - return errors.New("--sandbox=container requires the MITM proxy; server has it disabled") + return errors.New("--isolation=container requires the MITM proxy; server has it disabled") } if mitmPort == 0 { mitmPort = DefaultMITMPort @@ -152,17 +152,17 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st } } - sessionID, err := sandbox.NewSessionID() + sessionID, err := isolation.NewSessionID() if err != nil { return err } - hostCAPath, err := sandbox.WriteHostCAFile(pem, sessionID) + hostCAPath, err := isolation.WriteHostCAFile(pem, sessionID) if err != nil { return fmt.Errorf("write CA: %w", err) } - network, err := sandbox.CreatePerInvocationNetwork(ctx, sessionID) + network, err := isolation.CreatePerInvocationNetwork(ctx, sessionID) if err != nil { return fmt.Errorf("create docker network: %w", err) } @@ -171,7 +171,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st // cleanup exec itself. cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = sandbox.RemoveNetwork(cleanup, network.Name) + _ = isolation.RemoveNetwork(cleanup, network.Name) }() if !homeShared && !shareAgentDir { @@ -182,23 +182,23 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st // is opt-in persistent; host-bind mode never creates one. cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = sandbox.RemoveVolume(cleanup, sandbox.ClaudeHomeVolumeName(sessionID)) + _ = isolation.RemoveVolume(cleanup, isolation.ClaudeHomeVolumeName(sessionID)) }() } - bindIP := sandbox.HostBindIP(network) + bindIP := isolation.HostBindIP(network) if bindIP == nil { return errors.New("could not determine host bind IP for forwarder") } - fwd, err := sandbox.StartForwarder(ctx, bindIP, upstreamHTTPPort, mitmPort) + fwd, err := isolation.StartForwarder(ctx, bindIP, upstreamHTTPPort, mitmPort) if err != nil { return fmt.Errorf("start forwarder: %w", err) } defer func() { _ = fwd.Close() }() image, _ := cmd.Flags().GetString("image") - imageRef, err := sandbox.EnsureImage(ctx, image, os.Stderr) + imageRef, err := isolation.EnsureImage(ctx, image, os.Stderr) if err != nil { return err } @@ -208,13 +208,13 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st return fmt.Errorf("getwd: %w", err) } - env := sandbox.BuildContainerEnv(scopedToken, vault, fwd.HTTPPort, fwd.MITMPort, mitmTLS) + env := isolation.BuildContainerEnv(scopedToken, vault, fwd.HTTPPort, fwd.MITMPort, mitmTLS) mounts, _ := cmd.Flags().GetStringArray("mount") keep, _ := cmd.Flags().GetBool("keep") noFirewall, _ := cmd.Flags().GetBool("no-firewall") - dockerArgs, err := sandbox.BuildRunArgs(sandbox.Config{ + dockerArgs, err := isolation.BuildRunArgs(isolation.Config{ ImageRef: imageRef, SessionID: sessionID, WorkDir: workDir, @@ -245,7 +245,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st } fmt.Fprintf(os.Stderr, "%s routing container HTTPS through MITM on %s:%d (container view: host.docker.internal:%d)\n", successText("agent-vault:"), bindIP, fwd.MITMPort, fwd.MITMPort) - fmt.Fprintf(os.Stderr, "%s starting %s in sandbox (%s)...\n\n", + fmt.Fprintf(os.Stderr, "%s starting %s with isolation=container (%s)...\n\n", successText("agent-vault:"), boldText(args[0]), network.Name) // Fork docker (instead of syscall.Exec) so the forwarder stays diff --git a/cmd/run_container_test.go b/cmd/run_container_test.go index 6bc4f95..c1d92ab 100644 --- a/cmd/run_container_test.go +++ b/cmd/run_container_test.go @@ -8,7 +8,7 @@ import ( ) -func TestSandboxFlagsRegistered(t *testing.T) { +func TestIsolationFlagsRegistered(t *testing.T) { vCmd := findSubcommand(rootCmd, "vault") if vCmd == nil { t.Fatal("vault command not found") @@ -18,25 +18,25 @@ func TestSandboxFlagsRegistered(t *testing.T) { t.Fatal("vault run subcommand not found") } - for _, name := range []string{"sandbox", "image", "mount", "keep", "no-firewall", "home-volume-shared", "share-agent-dir"} { + for _, name := range []string{"isolation", "image", "mount", "keep", "no-firewall", "home-volume-shared", "share-agent-dir"} { if rCmd.Flags().Lookup(name) == nil { t.Errorf("expected vault run flag --%s to be registered", name) } } - // --sandbox must be pflag.Value-typed so invalid values fail at parse time. - f := rCmd.Flags().Lookup("sandbox") + // --isolation must be pflag.Value-typed so invalid values fail at parse time. + f := rCmd.Flags().Lookup("isolation") if f == nil { - t.Fatal("--sandbox not registered") + t.Fatal("--isolation not registered") } if err := f.Value.Set("not-a-mode"); err == nil { - t.Error("expected --sandbox to reject invalid values at flag-parse time") + t.Error("expected --isolation to reject invalid values at flag-parse time") } } -func TestSandboxMode_Set(t *testing.T) { - var m SandboxMode - for _, v := range []string{"process", "container"} { +func TestIsolationMode_Set(t *testing.T) { + var m IsolationMode + for _, v := range []string{"host", "container"} { if err := (&m).Set(v); err != nil { t.Errorf("Set(%q): unexpected err %v", v, err) } @@ -44,7 +44,7 @@ func TestSandboxMode_Set(t *testing.T) { t.Errorf("after Set(%q), m = %q", v, m) } } - for _, bad := range []string{"", "Process", "CONTAINER", "vm", "docker"} { + for _, bad := range []string{"", "Host", "CONTAINER", "vm", "docker", "process"} { err := (&m).Set(bad) if err == nil { t.Errorf("Set(%q): expected error, got nil", bad) @@ -56,23 +56,23 @@ func TestSandboxMode_Set(t *testing.T) { } } -func TestValidateSandboxFlagConflicts(t *testing.T) { +func TestValidateIsolationFlagConflicts(t *testing.T) { tests := []struct { name string - mode SandboxMode + mode IsolationMode setArgs []string wantErr string // substring; empty means expect nil }{ - {"process mode, no container flags set", SandboxProcess, nil, ""}, - {"container mode, all flags allowed", SandboxContainer, []string{"--image=foo", "--keep", "--no-firewall", "--home-volume-shared", "--mount=/a:/b"}, ""}, - {"process mode rejects --image", SandboxProcess, []string{"--image=foo"}, "--image requires --sandbox=container"}, - {"process mode rejects --mount", SandboxProcess, []string{"--mount=/a:/b"}, "--mount requires --sandbox=container"}, - {"process mode rejects --keep", SandboxProcess, []string{"--keep"}, "--keep requires --sandbox=container"}, - {"process mode rejects --no-firewall", SandboxProcess, []string{"--no-firewall"}, "--no-firewall requires --sandbox=container"}, - {"process mode rejects --home-volume-shared", SandboxProcess, []string{"--home-volume-shared"}, "--home-volume-shared requires --sandbox=container"}, - {"process mode rejects --share-agent-dir", SandboxProcess, []string{"--share-agent-dir"}, "--share-agent-dir requires --sandbox=container"}, - {"container mode accepts --share-agent-dir alone", SandboxContainer, []string{"--share-agent-dir"}, ""}, - {"container mode rejects --no-mitm", SandboxContainer, []string{"--no-mitm"}, "--no-mitm requires --sandbox=process"}, + {"host mode, no container flags set", IsolationHost, nil, ""}, + {"container mode, all flags allowed", IsolationContainer, []string{"--image=foo", "--keep", "--no-firewall", "--home-volume-shared", "--mount=/a:/b"}, ""}, + {"host mode rejects --image", IsolationHost, []string{"--image=foo"}, "--image requires --isolation=container"}, + {"host mode rejects --mount", IsolationHost, []string{"--mount=/a:/b"}, "--mount requires --isolation=container"}, + {"host mode rejects --keep", IsolationHost, []string{"--keep"}, "--keep requires --isolation=container"}, + {"host mode rejects --no-firewall", IsolationHost, []string{"--no-firewall"}, "--no-firewall requires --isolation=container"}, + {"host mode rejects --home-volume-shared", IsolationHost, []string{"--home-volume-shared"}, "--home-volume-shared requires --isolation=container"}, + {"host mode rejects --share-agent-dir", IsolationHost, []string{"--share-agent-dir"}, "--share-agent-dir requires --isolation=container"}, + {"container mode accepts --share-agent-dir alone", IsolationContainer, []string{"--share-agent-dir"}, ""}, + {"container mode rejects --no-mitm", IsolationContainer, []string{"--no-mitm"}, "--no-mitm requires --isolation=host"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -80,7 +80,7 @@ func TestValidateSandboxFlagConflicts(t *testing.T) { if err := cmd.ParseFlags(tc.setArgs); err != nil { t.Fatalf("ParseFlags(%v): %v", tc.setArgs, err) } - err := validateSandboxFlagConflicts(cmd, tc.mode) + err := validateIsolationFlagConflicts(cmd, tc.mode) if tc.wantErr == "" { if err != nil { t.Errorf("expected nil err, got %v", err) @@ -131,9 +131,9 @@ func TestValidateContainerFlagCombos(t *testing.T) { // newRunCommandForTest isolates flag `Changed` state per subtest; runCmd // itself would leak pflag state across ParseFlags calls. func newRunCommandForTest() *cobra.Command { - var sbx SandboxMode + var iso IsolationMode c := &cobra.Command{Use: "run-test"} - c.Flags().Var(&sbx, "sandbox", "") + c.Flags().Var(&iso, "isolation", "") c.Flags().String("image", "", "") c.Flags().StringArray("mount", nil, "") c.Flags().Bool("keep", false, "") diff --git a/cmd/run_test.go b/cmd/run_test.go index 9bff02c..d182e50 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -17,7 +17,7 @@ import ( // on `vault run` and registered locally on the top-level `run`. var expectedRunFlags = []string{ "address", "role", "ttl", "no-mitm", "vault", - "sandbox", "image", "mount", "keep", "no-firewall", + "isolation", "image", "mount", "keep", "no-firewall", "home-volume-shared", "share-agent-dir", } diff --git a/cmd/sandbox_flag.go b/cmd/sandbox_flag.go deleted file mode 100644 index dd42dd8..0000000 --- a/cmd/sandbox_flag.go +++ /dev/null @@ -1,30 +0,0 @@ -package cmd - -import "fmt" - -// SandboxMode selects how `vault run` isolates the child agent. -type SandboxMode string - -const ( - SandboxProcess SandboxMode = "process" - SandboxContainer SandboxMode = "container" -) - -func (m *SandboxMode) String() string { - if *m == "" { - return string(SandboxProcess) - } - return string(*m) -} - -func (m *SandboxMode) Set(v string) error { - switch SandboxMode(v) { - case SandboxProcess, SandboxContainer: - *m = SandboxMode(v) - return nil - default: - return fmt.Errorf("must be one of: process, container") - } -} - -func (*SandboxMode) Type() string { return "string" } diff --git a/cmd/skill_cli.md b/cmd/skill_cli.md index e73ef9b..27a17ff 100644 --- a/cmd/skill_cli.md +++ b/cmd/skill_cli.md @@ -41,7 +41,7 @@ You have access to Agent Vault, a transparent HTTPS proxy that injects credentia `agent-vault run` also pre-configures `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) so HTTPS calls from your process route through the broker transparently. You don't manage these yourself. -Under `--sandbox=container`, the same env shape is injected inside a Docker container, but the proxy URL host is `host.docker.internal` instead of `127.0.0.1` and egress to any other destination is blocked by iptables. From your perspective nothing changes — standard HTTP clients pick up the envvars as normal. +Under `--isolation=container`, the same env shape is injected inside a Docker container, but the proxy URL host is `host.docker.internal` instead of `127.0.0.1` and egress to any other destination is blocked by iptables. From your perspective nothing changes — standard HTTP clients pick up the envvars as normal. ## Discover Available Services (Start Here) diff --git a/cmd/skill_http.md b/cmd/skill_http.md index e0196ea..e00dfd4 100644 --- a/cmd/skill_http.md +++ b/cmd/skill_http.md @@ -40,7 +40,7 @@ You have access to Agent Vault, a transparent HTTPS proxy that injects credentia `vault run` also pre-configures `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) so HTTPS calls from your process route through the broker transparently. You don't manage these yourself. -Under `--sandbox=container`, the same env shape is injected inside a Docker container, but the proxy URL host is `host.docker.internal` instead of `127.0.0.1` and egress to any other destination is blocked by iptables. From your perspective nothing changes — standard HTTP clients pick up the envvars as normal. +Under `--isolation=container`, the same env shape is injected inside a Docker container, but the proxy URL host is `host.docker.internal` instead of `127.0.0.1` and egress to any other destination is blocked by iptables. From your perspective nothing changes — standard HTTP clients pick up the envvars as normal. ## Discover Available Services (Start Here) diff --git a/docs/docs.json b/docs/docs.json index 1470f86..0591e2d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -41,7 +41,7 @@ "pages": [ "guides/connect-coding-agent", "guides/connect-custom-agent", - "guides/container-sandbox" + "guides/container-isolation" ] }, { diff --git a/docs/guides/container-sandbox.mdx b/docs/guides/container-isolation.mdx similarity index 67% rename from docs/guides/container-sandbox.mdx rename to docs/guides/container-isolation.mdx index a67f1de..6052d60 100644 --- a/docs/guides/container-sandbox.mdx +++ b/docs/guides/container-isolation.mdx @@ -1,47 +1,47 @@ --- -title: "Container sandbox" +title: "Container isolation" description: "Run `vault run` agents inside a Docker container with iptables-locked egress so the child cannot reach the network outside the Agent Vault proxy, no matter what it tries." --- -`agent-vault vault run` has two sandbox modes: +`agent-vault vault run` has two isolation modes: -- **`--sandbox=process`** (default) — forks the agent with `HTTPS_PROXY` and CA-trust env vars pointing at Agent Vault. Cooperative: a misbehaving or malicious agent can unset the env, spawn a subprocess that doesn't inherit them, use raw sockets, or exfiltrate over DNS. -- **`--sandbox=container`** — launches the agent inside a Docker container whose egress is locked down with iptables. Non-cooperative: the only TCP destination the container can reach is the Agent Vault proxy. Everything else is dropped at the kernel. +- **`--isolation=host`** (default) — forks the agent on the host with `HTTPS_PROXY` and CA-trust env vars pointing at Agent Vault. Cooperative: a misbehaving or malicious agent can unset the env, spawn a subprocess that doesn't inherit them, use raw sockets, or exfiltrate over DNS. +- **`--isolation=container`** — launches the agent inside a Docker container whose egress is locked down with iptables. Non-cooperative: the only TCP destination the container can reach is the Agent Vault proxy. Everything else is dropped at the kernel. - Container mode is opt-in while it stabilizes. Process mode remains the default. + Container mode is opt-in while it stabilizes. Host mode remains the default. ## Quick start ```bash -agent-vault vault run --sandbox=container -- claude +agent-vault vault run --isolation=container -- claude ``` -First run takes ~60s while the sandbox image builds. Subsequent runs reuse the cached image. +First run takes ~60s while the isolation image builds. Subsequent runs reuse the cached image. -## What's inside the sandbox +## What's inside the container -- **Image**: `agent-vault/sandbox:`, built on first use from an embedded Dockerfile. Debian-slim + Node 22 + `@anthropic-ai/claude-code` + `iptables` / `gosu` / `curl` / `git` / `python3`. +- **Image**: `agent-vault/isolation:`, built on first use from an embedded Dockerfile. Debian-slim + Node 22 + `@anthropic-ai/claude-code` + `iptables` / `gosu` / `curl` / `git` / `python3`. - **Network**: a dedicated per-invocation Docker bridge network (`agent-vault-`). Not the default bridge — other containers you're running cannot reach the forwarder. - **User**: `claude` (UID != 0), dropped via `gosu` after `init-firewall.sh` runs as root. `--security-opt=no-new-privileges` + `--cap-drop=ALL` with only `NET_ADMIN` / `NET_RAW` (for iptables), `SETUID` / `SETGID` (so gosu can change UID), and `KILL` (so tini at PID 1 can forward TTY signals across the UID boundary) added. Docker doesn't grant container caps as *ambient* caps to non-root processes, so `claude` post-gosu has an empty effective cap set regardless. - **Egress policy**: `OUTPUT DROP` by default on both IPv4 (`iptables`) and IPv6 (`ip6tables`). `ACCEPT` only for loopback, ESTABLISHED/RELATED replies, and the two Agent Vault ports at `host.docker.internal` over IPv4 (the MITM path is v4-only by construction — we resolve via `getent ahostsv4`). **No DNS rule**: `host.docker.internal` is resolved via `/etc/hosts` (`docker run --add-host=host.docker.internal:host-gateway`), closing the DNS-exfiltration channel. - **Mounts**: - `$PWD → /workspace` (read-write, your project) - - `~/.agent-vault/sandbox/ca-.pem → /etc/agent-vault/ca.pem` (read-only, MITM CA) + - `~/.agent-vault/isolation/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.) ## Flags | Flag | Default | Description | |------|---------|-------------| -| `--sandbox` | `process` | `process` (default) or `container`. Also read from `AGENT_VAULT_SANDBOX`. | +| `--isolation` | `host` | `host` (default) or `container`. Also read from `AGENT_VAULT_ISOLATION`. | | `--image` | | Override the bundled image. Use for your own base (different agent, pinned versions). | | `--mount` | | Extra bind mount `src:dst[:ro]`; repeatable. Host paths are symlink-resolved before validation; binds into `~/.agent-vault/` or `/var/run/docker.sock` are rejected. | | `--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's agent state dir (`~/.claude`) into the container so it reuses your host login (auth, project history, MCP config). Mutually exclusive with `--home-volume-shared`. | ## Bundled image vs `--image` @@ -52,10 +52,10 @@ The default image pins `@anthropic-ai/claude-code` at build time. If you want a # supports `gosu`, has `iptables` in PATH, and ends with: # ENTRYPOINT ["/usr/local/sbin/entrypoint.sh"] # where entrypoint.sh calls init-firewall.sh then execs gosu "$@" -# — the bundled assets in internal/sandbox/assets/ are the reference.) -docker build -t my-org/my-agent-sandbox:v1 . +# — the bundled assets in internal/isolation/assets/ are the reference.) +docker build -t my-org/my-agent-isolation:v1 . -agent-vault vault run --sandbox=container --image=my-org/my-agent-sandbox:v1 -- claude +agent-vault vault run --isolation=container --image=my-org/my-agent-isolation:v1 -- claude ``` ## Agent state directory: three modes @@ -66,19 +66,19 @@ agent-vault vault run --sandbox=container --image=my-org/my-agent-sandbox:v1 -- 2. **Shared docker volume (`--home-volume-shared`).** Persistent named docker volume across invocations. Auth persists. ```bash - agent-vault vault run --sandbox=container --home-volume-shared -- claude + agent-vault vault run --isolation=container --home-volume-shared -- claude ``` 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's `~/.claude` (`--share-agent-dir`).** Reuses your existing host login — starting the container feels identical to running `claude` on the host (no onboarding, your project history is there). Mutually exclusive with `--home-volume-shared`. ```bash - agent-vault vault run --sandbox=container --share-agent-dir -- claude + agent-vault vault run --isolation=container --share-agent-dir -- claude ``` - 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. + Concurrency matches the status quo of running `claude` directly on the host — session files are UUID-keyed, so two concurrent containers 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). + **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 container — Claude will write a fresh file itself). -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. +Security note: container isolation 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 agent inside the container 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 @@ -99,7 +99,7 @@ Security note: the sandbox protects *vault-brokered* credentials (Stripe, GitHub - **Linux**: supported. Requires Docker 20.10+ for `--add-host=host.docker.internal:host-gateway`. - **macOS (Docker Desktop)**: supported. -- **Windows**: not supported in v1. `vault run --sandbox=container` errors on Windows. +- **Windows**: not supported in v1. `vault run --isolation=container` errors on Windows. ## Troubleshooting @@ -111,9 +111,9 @@ Security note: the sandbox protects *vault-brokered* credentials (Stripe, GitHub **`iptables -S OUTPUT` in the container shows `DROP` but the agent still makes external calls** — that's impossible unless you passed `--no-firewall`. Check for the loud warning banner on startup. -**Containers leak** after crashes — `agent-vault vault run --sandbox=container` runs `PruneStaleNetworks` at startup, removing any `agent-vault-*` networks older than 60 seconds with zero attached containers. The 60-second grace window prevents racing invocations from deleting each other's freshly-created networks. +**Containers leak** after crashes — `agent-vault vault run --isolation=container` runs `PruneStaleNetworks` at startup, removing any `agent-vault-*` networks older than 60 seconds with zero attached containers. The 60-second grace window prevents racing invocations from deleting each other's freshly-created networks. ## Related -- [Connect a coding agent](/guides/connect-coding-agent) — the standard `--sandbox=process` workflow. +- [Connect a coding agent](/guides/connect-coding-agent) — the standard `--isolation=host` workflow. - [CLI reference](/reference/cli#agent-vault-vault-run) — full flag table. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 81bfdb4..b79a6e2 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -272,13 +272,13 @@ description: "Complete reference for all Agent Vault CLI commands." | `--role` | `proxy` | Vault role for the session: `proxy`, `member`, or `admin` | | `--ttl` | `0` | Session TTL in seconds (300–604800). Default: server default (24h). | | `--no-mitm` | `false` | Skip HTTPS_PROXY/CA env injection; rely solely on the explicit `/proxy/{host}/{path}` endpoint. | - | `--sandbox` | `process` | Sandbox mode for the child: `process` (default, cooperative) or `container` (non-cooperative Docker sandbox; see [Container sandbox](/guides/container-sandbox)). Also read from `AGENT_VAULT_SANDBOX`. | - | `--image` | | Override the bundled container image (`--sandbox=container` only). | - | `--mount` | | Extra bind mount `src:dst[:ro]`; repeatable (`--sandbox=container` only). Host paths are EvalSymlinks-resolved; reserved paths rejected. | - | `--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`. | + | `--isolation` | `host` | Isolation mode for the child: `host` (default, cooperative — runs on the host with HTTPS_PROXY) or `container` (non-cooperative Docker container; see [Container isolation](/guides/container-isolation)). Also read from `AGENT_VAULT_ISOLATION`. | + | `--image` | | Override the bundled container image (`--isolation=container` only). | + | `--mount` | | Extra bind mount `src:dst[:ro]`; repeatable (`--isolation=container` only). Host paths are EvalSymlinks-resolved; reserved paths rejected. | + | `--keep` | `false` | Omit `--rm` from `docker run` (`--isolation=container` only; useful for debugging). | + | `--no-firewall` | `false` | Skip the iptables egress lockdown (`--isolation=container` only; debug, prints a warning). | + | `--home-volume-shared` | `false` | Share `/home/claude/.claude` across invocations via a persistent docker volume (`--isolation=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 container reuses your host login (`--isolation=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/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx index f53d46e..1ece56f 100644 --- a/docs/self-hosting/environment-variables.mdx +++ b/docs/self-hosting/environment-variables.mdx @@ -21,7 +21,7 @@ description: "All environment variables used to configure Agent Vault" | `AGENT_VAULT_LOGS_MAX_AGE_HOURS` | `168` | Retention for the per-vault request log (surfaced in **Vault → Logs**). Rows older than this many hours are trimmed by a background job every 15 minutes. Only secret-free metadata is stored (method, host, path, status, latency, matched service, credential key names) — never bodies or query strings. | | `AGENT_VAULT_LOGS_MAX_ROWS_PER_VAULT` | `10000` | Per-vault row cap for the request log. Whichever limit (age or rows) hits first wins, so heavy-traffic vaults retain a shorter window than the time-based TTL alone would suggest. Set to `0` to disable the row cap. | | `AGENT_VAULT_LOGS_RETENTION_LOCK` | `false` | When `true`, any owner-UI overrides for log retention are ignored and env values (or defaults) are pinned. Use when you want retention limits controlled only by the operator. | -| `AGENT_VAULT_SANDBOX` | `process` | Default sandbox mode for `agent-vault vault run`. `process` forks the child with `HTTPS_PROXY` envvars (cooperative). `container` launches it inside a Docker container with iptables-locked egress (non-cooperative; see [Container sandbox](/guides/container-sandbox)). The `--sandbox` flag overrides this. | +| `AGENT_VAULT_ISOLATION` | `host` | Default isolation mode for `agent-vault vault run`. `host` forks the child on the host with `HTTPS_PROXY` envvars (cooperative). `container` launches it inside a Docker container with iptables-locked egress (non-cooperative; see [Container isolation](/guides/container-isolation)). The `--isolation` flag overrides this. | Master password resolution order: diff --git a/internal/ca/sandbox_sni_test.go b/internal/ca/isolation_sni_test.go similarity index 85% rename from internal/ca/sandbox_sni_test.go rename to internal/ca/isolation_sni_test.go index 26939ff..6a06115 100644 --- a/internal/ca/sandbox_sni_test.go +++ b/internal/ca/isolation_sni_test.go @@ -5,12 +5,12 @@ import ( ) // TestValidateSNI_HostDockerInternal locks in the invariant that the -// sandbox container mode depends on: a TLS ClientHello with +// container isolation mode depends on: a TLS ClientHello with // SNI=host.docker.internal (what the container's HTTPS client sends // when dialing the forwarder) must be accepted by MintLeaf, so the // inner MITM listener can mint a matching leaf and the handshake // succeeds. Tightening validateSNI without updating this test would -// silently break `vault run --sandbox=container`. +// silently break `vault run --isolation=container`. func TestValidateSNI_HostDockerInternal(t *testing.T) { isIP, err := validateSNI("host.docker.internal") if err != nil { diff --git a/internal/sandbox/assets/Dockerfile b/internal/isolation/assets/Dockerfile similarity index 89% rename from internal/sandbox/assets/Dockerfile rename to internal/isolation/assets/Dockerfile index ad3aafb..b452d38 100644 --- a/internal/sandbox/assets/Dockerfile +++ b/internal/isolation/assets/Dockerfile @@ -1,4 +1,4 @@ -# Agent Vault sandbox image. Built locally on first use by EnsureImage. +# Agent Vault isolation image. Built locally on first use by EnsureImage. FROM node:22-bookworm-slim@sha256:d415caac2f1f77b98caaf9415c5f807e14bc8d7bdea62561ea2fef4fbd08a73c RUN apt-get update \ diff --git a/internal/sandbox/assets/entrypoint.sh b/internal/isolation/assets/entrypoint.sh similarity index 100% rename from internal/sandbox/assets/entrypoint.sh rename to internal/isolation/assets/entrypoint.sh diff --git a/internal/sandbox/assets/init-firewall.sh b/internal/isolation/assets/init-firewall.sh similarity index 100% rename from internal/sandbox/assets/init-firewall.sh rename to internal/isolation/assets/init-firewall.sh diff --git a/internal/sandbox/cacopy.go b/internal/isolation/cacopy.go similarity index 79% rename from internal/sandbox/cacopy.go rename to internal/isolation/cacopy.go index 58812f9..29b63f0 100644 --- a/internal/sandbox/cacopy.go +++ b/internal/isolation/cacopy.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "fmt" @@ -10,12 +10,12 @@ import ( ) const ( - // sandboxDirName lives under ~/.agent-vault so ungraceful exits can + // isolationDirName lives under ~/.agent-vault so ungraceful exits can // be cleaned up by PruneHostCAFiles on the next run. - sandboxDirName = "sandbox" - caPrefix = "ca-" - caSuffix = ".pem" - caStaleTTL = 24 * time.Hour + isolationDirName = "isolation" + caPrefix = "ca-" + caSuffix = ".pem" + caStaleTTL = 24 * time.Hour ) // sessionIDRE matches the output of NewSessionID — hex-only, so a @@ -23,7 +23,7 @@ const ( var sessionIDRE = regexp.MustCompile(`^[0-9a-f]+$`) // WriteHostCAFile writes the MITM CA cert to -// ~/.agent-vault/sandbox/ca-.pem with mode 0o644 (the +// ~/.agent-vault/isolation/ca-.pem with mode 0o644 (the // container's unprivileged claude user must read it via the bind // mount). The enclosing directory stays 0o700 so only the host user // and root can read the file on the host side. @@ -33,12 +33,12 @@ func WriteHostCAFile(pem []byte, sessionID string) (string, error) { if !sessionIDRE.MatchString(sessionID) { return "", fmt.Errorf("WriteHostCAFile: sessionID must be hex, got %q", sessionID) } - dir, err := hostSandboxDir() + dir, err := hostIsolationDir() if err != nil { return "", err } if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("create sandbox dir: %w", err) + return "", fmt.Errorf("create isolation dir: %w", err) } path := filepath.Join(dir, caPrefix+sessionID+caSuffix) if err := os.WriteFile(path, pem, 0o600); err != nil { @@ -53,12 +53,12 @@ func WriteHostCAFile(pem []byte, sessionID string) (string, error) { return path, nil } -// PruneHostCAFiles removes ca-*.pem files in ~/.agent-vault/sandbox/ +// PruneHostCAFiles removes ca-*.pem files in ~/.agent-vault/isolation/ // older than caStaleTTL. Best-effort — errors are ignored because this // is background cleanup, not correctness-critical. Called at the top of // each container-mode vault run. func PruneHostCAFiles() { - dir, err := hostSandboxDir() + dir, err := hostIsolationDir() if err != nil { return } @@ -86,10 +86,10 @@ func PruneHostCAFiles() { } } -func hostSandboxDir() (string, error) { +func hostIsolationDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home dir: %w", err) } - return filepath.Join(home, ".agent-vault", sandboxDirName), nil + return filepath.Join(home, ".agent-vault", isolationDirName), nil } diff --git a/internal/sandbox/cacopy_test.go b/internal/isolation/cacopy_test.go similarity index 93% rename from internal/sandbox/cacopy_test.go rename to internal/isolation/cacopy_test.go index 00b875f..753c6d9 100644 --- a/internal/sandbox/cacopy_test.go +++ b/internal/isolation/cacopy_test.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "os" @@ -17,7 +17,7 @@ func TestWriteHostCAFile_WritesAt0o644(t *testing.T) { t.Fatalf("WriteHostCAFile: %v", err) } - wantDir := filepath.Join(home, ".agent-vault", sandboxDirName) + wantDir := filepath.Join(home, ".agent-vault", isolationDirName) wantFile := filepath.Join(wantDir, caPrefix+"deadbeef12345678"+caSuffix) if path != wantFile { t.Errorf("path = %q, want %q", path, wantFile) @@ -77,7 +77,7 @@ func TestPruneHostCAFiles_RemovesStaleOnly(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - dir := filepath.Join(home, ".agent-vault", sandboxDirName) + dir := filepath.Join(home, ".agent-vault", isolationDirName) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("mkdir: %v", err) } @@ -110,7 +110,7 @@ func TestPruneHostCAFiles_RemovesStaleOnly(t *testing.T) { } func TestPruneHostCAFiles_NoDirectoryIsNoError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) // fresh home, no sandbox dir created + t.Setenv("HOME", t.TempDir()) // fresh home, no isolation dir created // Must not panic or error even when the dir doesn't exist. PruneHostCAFiles() } diff --git a/internal/sandbox/docker.go b/internal/isolation/docker.go similarity index 97% rename from internal/sandbox/docker.go rename to internal/isolation/docker.go index 73456dc..1003977 100644 --- a/internal/sandbox/docker.go +++ b/internal/isolation/docker.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "errors" @@ -12,7 +12,7 @@ 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 + ImageRef string // "agent-vault/isolation:" 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 @@ -36,7 +36,7 @@ type parsedMount struct { // reservedContainerDsts are bind-mount destinations agent-vault owns. // A user --mount landing on one of these would silently replace our -// own mount and undo the sandbox guarantees. The entrypoint + firewall +// own mount and undo the isolation guarantees. The entrypoint + firewall // scripts are the image's trust path — overwriting either pre-entrypoint // would be a direct break-out. var reservedContainerDsts = []string{ @@ -76,7 +76,7 @@ func BuildRunArgs(cfg Config) ([]string, error) { // The CWD is bind-mounted read-write at /workspace. Subject it to // the same host-src validation as user --mount flags so running - // `vault run --sandbox=container` from inside ~/.agent-vault (which + // `vault run --isolation=container` from inside ~/.agent-vault (which // holds the encrypted CA key + vault database) does not expose // that dir to the container. resolvedWorkDir, err := filepath.EvalSymlinks(cfg.WorkDir) @@ -234,7 +234,7 @@ func parseAndValidateMount(raw, homeDir string) (parsedMount, error) { func validateHostSrc(resolved, homeDir string) error { if isDockerSocket(resolved) { - return errors.New("--mount: refusing to bind the docker socket (would undo every sandbox guarantee)") + return errors.New("--mount: refusing to bind the docker socket (would undo every isolation guarantee)") } if homeDir != "" { // Canonicalize homeDir so the prefix comparison is apples-to-apples diff --git a/internal/sandbox/docker_test.go b/internal/isolation/docker_test.go similarity index 98% rename from internal/sandbox/docker_test.go rename to internal/isolation/docker_test.go index a2eba52..81fdb80 100644 --- a/internal/sandbox/docker_test.go +++ b/internal/isolation/docker_test.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "os" @@ -12,7 +12,7 @@ import ( func baseConfig(t *testing.T) Config { t.Helper() return Config{ - ImageRef: "agent-vault/sandbox:deadbeef1234", + ImageRef: "agent-vault/isolation:deadbeef1234", SessionID: "abcd1234ef567890", WorkDir: t.TempDir(), HostCAPath: filepath.Join(t.TempDir(), "ca.pem"), @@ -288,7 +288,7 @@ func TestBuildRunArgs_UserMountAccepted(t *testing.T) { } // TestBuildRunArgs_RejectsCWDInsideAgentVaultDir pins the fix for the -// case where `vault run --sandbox=container` is invoked with the CWD +// case where `vault run --isolation=container` is invoked with the CWD // inside ~/.agent-vault — the vault's encrypted CA key and database // must not be bind-mounted into the container. func TestBuildRunArgs_RejectsCWDInsideAgentVaultDir(t *testing.T) { diff --git a/internal/sandbox/env.go b/internal/isolation/env.go similarity index 94% rename from internal/sandbox/env.go rename to internal/isolation/env.go index f2b6d41..2068b72 100644 --- a/internal/sandbox/env.go +++ b/internal/isolation/env.go @@ -1,6 +1,6 @@ -// Package sandbox builds the non-cooperative container sandbox that -// `vault run --sandbox=container` launches the child agent inside. -package sandbox +// Package isolation builds the non-cooperative container that +// `vault run --isolation=container` launches the child agent inside. +package isolation import ( "fmt" diff --git a/internal/sandbox/env_test.go b/internal/isolation/env_test.go similarity index 99% rename from internal/sandbox/env_test.go rename to internal/isolation/env_test.go index 8bdf022..f7707ac 100644 --- a/internal/sandbox/env_test.go +++ b/internal/isolation/env_test.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "net/url" diff --git a/internal/sandbox/forwarder.go b/internal/isolation/forwarder.go similarity index 99% rename from internal/sandbox/forwarder.go rename to internal/isolation/forwarder.go index 0971ed3..eaf8728 100644 --- a/internal/sandbox/forwarder.go +++ b/internal/isolation/forwarder.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "context" diff --git a/internal/sandbox/forwarder_test.go b/internal/isolation/forwarder_test.go similarity index 99% rename from internal/sandbox/forwarder_test.go rename to internal/isolation/forwarder_test.go index f1d6962..ce8794c 100644 --- a/internal/sandbox/forwarder_test.go +++ b/internal/isolation/forwarder_test.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "context" diff --git a/internal/sandbox/gateway.go b/internal/isolation/gateway.go similarity index 98% rename from internal/sandbox/gateway.go rename to internal/isolation/gateway.go index e1dd99b..2477ca9 100644 --- a/internal/sandbox/gateway.go +++ b/internal/isolation/gateway.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "net" diff --git a/internal/sandbox/image.go b/internal/isolation/image.go similarity index 83% rename from internal/sandbox/image.go rename to internal/isolation/image.go index 9b92778..923fcdc 100644 --- a/internal/sandbox/image.go +++ b/internal/isolation/image.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "context" @@ -12,16 +12,16 @@ import ( "path/filepath" ) -// embedded sandbox assets: Dockerfile + init/entrypoint scripts. The +// embedded isolation assets: Dockerfile + init/entrypoint scripts. The // sha256 of their concatenated bytes (sorted by name for stability) // becomes the image tag, so a binary that ships different assets // automatically produces a different tag on first use. // //go:embed assets/Dockerfile assets/init-firewall.sh assets/entrypoint.sh -var sandboxAssets embed.FS +var isolationAssets embed.FS const ( - sandboxImageRepo = "agent-vault/sandbox" + isolationImageRepo = "agent-vault/isolation" // assetsHashLen is 12 hex chars — plenty of collision resistance // for this purpose and short enough to read in docker image ls. assetsHashLen = 12 @@ -40,7 +40,7 @@ var assetFiles = []string{ // cache each docker-build the same content. Last writer wins, same // bytes — one extra minute of wasted CPU. Acceptable for v1. -// EnsureImage guarantees the sandbox image exists locally and returns +// EnsureImage guarantees the isolation image exists locally and returns // the fully qualified tag. If override is non-empty, the user's own // image is used as-is and no build is performed. // @@ -54,7 +54,7 @@ func EnsureImage(ctx context.Context, override string, stderr io.Writer) (string if err != nil { return "", err } - tag := sandboxImageRepo + ":" + hash + tag := isolationImageRepo + ":" + hash if imageExists(ctx, tag) { return tag, nil } @@ -63,10 +63,10 @@ func EnsureImage(ctx context.Context, override string, stderr io.Writer) (string if err != nil { return "", err } - fmt.Fprintln(stderr, "agent-vault: building sandbox image (one-time setup)...") + fmt.Fprintln(stderr, "agent-vault: building isolation image (one-time setup)...") build := exec.CommandContext(ctx, "docker", "build", "-t", tag, - "-t", sandboxImageRepo+":latest", + "-t", isolationImageRepo+":latest", dir, ) build.Stdout = stderr @@ -84,7 +84,7 @@ func imageExists(ctx context.Context, tag string) bool { func assetsHash() (string, error) { h := sha256.New() for _, name := range assetFiles { - data, err := sandboxAssets.ReadFile(name) + data, err := isolationAssets.ReadFile(name) if err != nil { return "", fmt.Errorf("read embedded asset %s: %w", name, err) } @@ -96,10 +96,10 @@ func assetsHash() (string, error) { } // unpackAssets writes the embedded files to -// ~/.agent-vault/sandbox// (idempotent) and returns the path. +// ~/.agent-vault/isolation// (idempotent) and returns the path. // Scripts are emitted 0o755 so docker build's COPY preserves mode. func unpackAssets(hash string) (string, error) { - dir, err := hostSandboxDir() + dir, err := hostIsolationDir() if err != nil { return "", err } @@ -108,7 +108,7 @@ func unpackAssets(hash string) (string, error) { return "", fmt.Errorf("mkdir %s: %w", outDir, err) } for _, name := range assetFiles { - data, err := sandboxAssets.ReadFile(name) + data, err := isolationAssets.ReadFile(name) if err != nil { return "", err } diff --git a/internal/sandbox/image_test.go b/internal/isolation/image_test.go similarity index 95% rename from internal/sandbox/image_test.go rename to internal/isolation/image_test.go index 396235c..dbae68e 100644 --- a/internal/sandbox/image_test.go +++ b/internal/isolation/image_test.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "bytes" @@ -23,12 +23,12 @@ func TestAssetsHash_Format(t *testing.T) { // would bust every user's local image cache without them realizing. // If an embedded asset changes, this test must be updated alongside // the change — forcing the PR author to acknowledge that users will -// rebuild the image on their next `vault run --sandbox=container`. +// rebuild the image on their next `vault run --isolation=container`. // // Treat a diff on this constant as intentional. Update when changing // Dockerfile / init-firewall.sh / entrypoint.sh. func TestAssetsHash_Stable(t *testing.T) { - const want = "17980b6cccb1" + const want = "35e87377cffa" got, err := assetsHash() if err != nil { t.Fatalf("assetsHash: %v", err) diff --git a/internal/sandbox/integration_test.go b/internal/isolation/integration_test.go similarity index 95% rename from internal/sandbox/integration_test.go rename to internal/isolation/integration_test.go index 19fb7d0..dcd090f 100644 --- a/internal/sandbox/integration_test.go +++ b/internal/isolation/integration_test.go @@ -1,9 +1,9 @@ //go:build docker_integration -// To run: go test -tags docker_integration ./internal/sandbox/ -run Integration -v +// To run: go test -tags docker_integration ./internal/isolation/ -run Integration -v // Requires: docker daemon, network access to node:22-bookworm-slim + // debian apt mirrors on first run (for the image build). -package sandbox +package isolation import ( "bytes" @@ -123,8 +123,8 @@ func TestIntegration_ImageBuildCaches(t *testing.T) { if err != nil { t.Fatalf("EnsureImage (first): %v", err) } - if !strings.HasPrefix(ref1, sandboxImageRepo+":") { - t.Errorf("ref = %q, want %s:", ref1, sandboxImageRepo) + if !strings.HasPrefix(ref1, isolationImageRepo+":") { + t.Errorf("ref = %q, want %s:", ref1, isolationImageRepo) } var out2 bytes.Buffer @@ -140,7 +140,7 @@ func TestIntegration_ImageBuildCaches(t *testing.T) { } } -// runInFirewalledContainer runs a one-off bash command inside the sandbox +// runInFirewalledContainer runs a one-off bash command inside the isolation // image on a per-invocation network with init-firewall.sh already applied. // The network is cleaned up on test exit. func runInFirewalledContainer(t *testing.T, ctx context.Context, shellCmd string) ([]byte, error) { @@ -175,7 +175,7 @@ func runInFirewalledContainer(t *testing.T, ctx context.Context, shellCmd string // routable IPv4 literal must fail because iptables DROPs the SYN. func TestIntegration_EgressBlockedEndToEnd(t *testing.T) { if testing.Short() { - t.Skip("skip in -short mode; builds the sandbox image") + t.Skip("skip in -short mode; builds the isolation image") } requireDocker(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) @@ -195,7 +195,7 @@ func TestIntegration_EgressBlockedEndToEnd(t *testing.T) { } // TestIntegration_EgressBlocked_Bypasses is the "bypasses the threat -// model actually cares about" suite. A non-cooperative sandbox has to +// model actually cares about" suite. A non-cooperative isolation has to // block malicious escape attempts, not just well-behaved clients — each // case probes a different channel a compromised agent might try. // @@ -203,7 +203,7 @@ func TestIntegration_EgressBlockedEndToEnd(t *testing.T) { // BLOCKED (firewall held; test passes). func TestIntegration_EgressBlocked_Bypasses(t *testing.T) { if testing.Short() { - t.Skip("skip in -short mode; builds the sandbox image") + t.Skip("skip in -short mode; builds the isolation image") } requireDocker(t) @@ -255,7 +255,7 @@ func TestIntegration_EgressBlocked_Bypasses(t *testing.T) { // threat model is wrong. func TestIntegration_EntrypointDropsToClaudeUser(t *testing.T) { if testing.Short() { - t.Skip("skip in -short mode; builds the sandbox image") + t.Skip("skip in -short mode; builds the isolation image") } requireDocker(t) diff --git a/internal/sandbox/network.go b/internal/isolation/network.go similarity index 99% rename from internal/sandbox/network.go rename to internal/isolation/network.go index 8b182a7..72b3ae4 100644 --- a/internal/sandbox/network.go +++ b/internal/isolation/network.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "context" @@ -17,7 +17,7 @@ import ( // without touching networks from other tools. The same label key is // applied to per-invocation claude-home volumes for symmetric pruning. const ( - NetworkLabelKey = "agent-vault-sandbox" + NetworkLabelKey = "agent-vault-isolation" NetworkLabelValue = "1" NetworkNamePrefix = "agent-vault-" VolumeNamePrefix = "agent-vault-claude-home-" diff --git a/internal/sandbox/network_test.go b/internal/isolation/network_test.go similarity index 99% rename from internal/sandbox/network_test.go rename to internal/isolation/network_test.go index f47ee82..fcdabc3 100644 --- a/internal/sandbox/network_test.go +++ b/internal/isolation/network_test.go @@ -1,4 +1,4 @@ -package sandbox +package isolation import ( "net" diff --git a/internal/mitm/sandbox_loopback_test.go b/internal/mitm/isolation_loopback_test.go similarity index 83% rename from internal/mitm/sandbox_loopback_test.go rename to internal/mitm/isolation_loopback_test.go index a22e237..9694c31 100644 --- a/internal/mitm/sandbox_loopback_test.go +++ b/internal/mitm/isolation_loopback_test.go @@ -6,11 +6,11 @@ import ( ) // TestIsLoopbackPeer_CoversForwarderLaundering pins the invariant that -// lets `vault run --sandbox=container` skip TierAuth rate limiting -// without any code change to the limiter: the sandbox forwarder dials +// lets `vault run --isolation=container` skip TierAuth rate limiting +// without any code change to the limiter: the isolation forwarder dials // 127.0.0.1:, so every container CONNECT arrives at the // MITM with a loopback RemoteAddr — matching the same exemption that -// the local-agent process path relies on. +// the host-mode path relies on. // // If this test ever fails, the forwarder is no longer laundering the // source IP (the forwarder changed how it dials upstream, or