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