diff --git a/.env.example b/.env.example index ae00235..219859c 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ # 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 + # Observability (optional) # AGENT_VAULT_LOG_LEVEL=info # info (default) | debug — debug emits one line per proxied request (no secret values) diff --git a/CLAUDE.md b/CLAUDE.md index dd320b2..c5b5261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +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. ## Where to look for details diff --git a/README.md b/README.md index 61c3c31..caf6fcc 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,14 @@ agent-vault vault run -- claude 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: + +```bash +agent-vault vault run --sandbox=container -- claude +``` + +See [Container sandbox](https://docs.agent-vault.dev/guides/container-sandbox) for the threat model and flags. + ### SDK — sandboxed agents (Docker, Daytona, E2B) For agents running inside containers, use the SDK from your orchestrator to mint a session and pass proxy config into the sandbox: diff --git a/cmd/run.go b/cmd/run.go index dcaf0d7..43e8fd2 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -13,6 +13,7 @@ import ( "strings" "syscall" + "github.com/Infisical/agent-vault/internal/sandbox" "github.com/Infisical/agent-vault/internal/session" "github.com/Infisical/agent-vault/internal/store" "github.com/charmbracelet/huh" @@ -25,6 +26,10 @@ var skillCLI string //go:embed skill_http.md var skillHTTP string +// sandboxMode is enum-typed so `--sandbox=foo` fails at flag-parse time +// with the allowed set, rather than deep inside RunE. +var sandboxMode SandboxMode + var runCmd = &cobra.Command{ Use: "run [flags] -- [args...]", Short: "Wrap an agent process with Agent Vault access", @@ -53,6 +58,24 @@ Example: Args: cobra.MinimumNArgs(1), DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { + // 0. Resolve sandbox mode and validate flag compatibility before any + // network I/O — the user sees conflicts immediately, not after + // a slow session-mint round-trip. + mode := sandboxMode + if mode == "" { + if v := os.Getenv("AGENT_VAULT_SANDBOX"); v != "" { + if err := mode.Set(v); err != nil { + return fmt.Errorf("AGENT_VAULT_SANDBOX: %w", err) + } + } + } + if mode == "" { + mode = SandboxProcess + } + if err := validateSandboxFlagConflicts(cmd, mode); err != nil { + return err + } + // 1. Load the admin session from agent-vault auth login. sess, err := ensureSession() if err != nil { @@ -78,6 +101,10 @@ Example: return err } + if mode == SandboxContainer { + return runContainer(cmd, args, scopedToken, addr, vault) + } + // 4. Resolve the target binary. binary, err := exec.LookPath(args[0]) if err != nil { @@ -276,23 +303,19 @@ func fetchUserVaults(addr, token string) ([]string, error) { return names, nil } -// mitmInjectedKeys is the set of env keys augmentEnvWithMITM manages on -// the child. Any pre-existing occurrence inherited from os.Environ() must -// be stripped before the new values are appended — POSIX getenv returns -// the *first* match in C code paths (glibc, curl, libcurl-backed Python), -// so a stale corporate HTTPS_PROXY from the parent shell would otherwise -// silently win and the MITM route would be bypassed entirely. -var mitmInjectedKeys = map[string]struct{}{ - "HTTPS_PROXY": {}, - "NO_PROXY": {}, - "NODE_USE_ENV_PROXY": {}, - "SSL_CERT_FILE": {}, - "NODE_EXTRA_CA_CERTS": {}, - "REQUESTS_CA_BUNDLE": {}, - "CURL_CA_BUNDLE": {}, - "GIT_SSL_CAINFO": {}, - "DENO_CERT": {}, -} +// mitmInjectedKeys is the keyset that BuildProxyEnv emits. Any +// pre-existing occurrence inherited from os.Environ() must be stripped +// before the new values are appended — POSIX getenv returns the *first* +// match in C code paths (glibc, curl, libcurl-backed Python), so a stale +// 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[k] = struct{}{} + } + return m +}() // stripEnvKeys returns env with every entry whose key (the part before // '=') appears in keys removed. Case-sensitive, matching how the kernel @@ -359,30 +382,16 @@ func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]stri mitmHost = h } } - scheme := "http" - if mitmTLS { - scheme = "https" - } - proxyURL := (&url.URL{ - Scheme: scheme, - User: url.UserPassword(token, vault), - Host: fmt.Sprintf("%s:%d", mitmHost, port), - }).String() env = stripEnvKeys(env, mitmInjectedKeys) - // CA trust variables must stay in sync with buildProxyEnv() in - // sdks/sdk-typescript/src/resources/sessions.ts. - env = append(env, - "HTTPS_PROXY="+proxyURL, - "NO_PROXY=localhost,127.0.0.1", - "NODE_USE_ENV_PROXY=1", - "SSL_CERT_FILE="+caPath, - "NODE_EXTRA_CA_CERTS="+caPath, - "REQUESTS_CA_BUNDLE="+caPath, - "CURL_CA_BUNDLE="+caPath, - "GIT_SSL_CAINFO="+caPath, - "DENO_CERT="+caPath, - ) + env = append(env, sandbox.BuildProxyEnv(sandbox.ProxyEnvParams{ + Host: mitmHost, + Port: port, + Token: token, + Vault: vault, + CAPath: caPath, + MITMTLS: mitmTLS, + })...) return env, port, true, nil } @@ -440,5 +449,12 @@ func init() { runCmd.Flags().Int("ttl", 0, "Session TTL in seconds (300–604800; default: server default 24h)") runCmd.Flags().Bool("no-mitm", false, "Skip HTTPS_PROXY/CA env injection for the child (explicit /proxy only)") + runCmd.Flags().Var(&sandboxMode, "sandbox", "Sandbox mode: process (default) or container") + runCmd.Flags().String("image", "", "Container image override (requires --sandbox=container)") + runCmd.Flags().StringArray("mount", nil, "Extra bind mount src:dst[:ro] (repeatable; requires --sandbox=container)") + runCmd.Flags().Bool("keep", false, "Don't pass --rm to docker (requires --sandbox=container)") + runCmd.Flags().Bool("no-firewall", false, "Skip iptables egress rules inside the container (requires --sandbox=container; debug only)") + runCmd.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") + vaultCmd.AddCommand(runCmd) } diff --git a/cmd/run_container.go b/cmd/run_container.go new file mode 100644 index 0000000..348d7d7 --- /dev/null +++ b/cmd/run_container.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "os/signal" + "runtime" + "strconv" + "syscall" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Infisical/agent-vault/internal/sandbox" +) + +// containerOnlyFlags are no-ops in process mode. processOnlyFlags 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"} + processOnlyFlags = []string{"no-mitm"} +) + +func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error { + var disallowed []string + var otherMode string + if mode == SandboxContainer { + disallowed = processOnlyFlags + otherMode = "process" + } else { + disallowed = containerOnlyFlags + otherMode = "container" + } + for _, name := range disallowed { + f := cmd.Flags().Lookup(name) + if f == nil || !f.Changed { + continue + } + return fmt.Errorf("--%s requires --sandbox=%s", name, otherMode) + } + return nil +} + +// runContainer launches the target agent inside a Docker container with +// 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) + } + if _, err := exec.LookPath("docker"); err != nil { + return errors.New("--sandbox=container: `docker` not found in PATH") + } + + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + // 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) + + // Pull the MITM CA from the server. Container mode always routes + // through MITM — --no-mitm is a process-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") + } + if mitmPort == 0 { + mitmPort = DefaultMITMPort + } + + // Upstream agent-vault HTTP port for the forwarder. Parsed from + // --address / session address, with DefaultPort as a fallback. + upstreamHTTPPort := DefaultPort + if u, perr := url.Parse(addr); perr == nil { + if p, cerr := strconv.Atoi(u.Port()); cerr == nil && p > 0 { + upstreamHTTPPort = p + } + } + + sessionID, err := sandbox.NewSessionID() + if err != nil { + return err + } + + hostCAPath, err := sandbox.WriteHostCAFile(pem, sessionID) + if err != nil { + return fmt.Errorf("write CA: %w", err) + } + + network, err := sandbox.CreatePerInvocationNetwork(ctx, sessionID) + if err != nil { + return fmt.Errorf("create docker network: %w", err) + } + defer func() { + // Detached context so a parent ctx cancel doesn't skip the + // cleanup exec itself. + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = sandbox.RemoveNetwork(cleanup, network.Name) + }() + + homeShared, _ := cmd.Flags().GetBool("home-volume-shared") + if !homeShared { + defer func() { + // Per-invocation volume: remove after the container exits + // so .claude state (auth tokens, session history) doesn't + // accumulate one volume per invocation. Shared-mode volume + // is opt-in persistent; never auto-remove. + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = sandbox.RemoveVolume(cleanup, sandbox.ClaudeHomeVolumeName(sessionID)) + }() + } + + bindIP := sandbox.HostBindIP(network) + if bindIP == nil { + return errors.New("could not determine host bind IP for forwarder") + } + + fwd, err := sandbox.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) + if err != nil { + return err + } + + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("getwd: %w", err) + } + + env := sandbox.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{ + ImageRef: imageRef, + SessionID: sessionID, + WorkDir: workDir, + HostCAPath: hostCAPath, + NetworkName: network.Name, + AttachTTY: term.IsTerminal(int(os.Stdin.Fd())), + Keep: keep, + NoFirewall: noFirewall, + HomeVolumeShared: homeShared, + Mounts: mounts, + Env: env, + CommandArgs: args, + }) + if err != nil { + return err + } + + dockerBin, err := exec.LookPath("docker") + if err != nil { + return err + } + + if noFirewall { + fmt.Fprintln(os.Stderr, "agent-vault: WARNING --no-firewall active, container egress is unrestricted") + } + 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", + successText("agent-vault:"), boldText(args[0]), network.Name) + + // Fork docker (instead of syscall.Exec) so the forwarder stays + // alive for the container's lifetime. Go listeners are FD_CLOEXEC, + // so exec'ing would close them before the container could dial + // host.docker.internal:, producing ECONNREFUSED on every + // HTTPS call through the MITM path. + // + // Docker is in our process group (default), so the kernel delivers + // TTY signals (SIGINT, SIGWINCH) to both docker and us. Docker's + // --init/tini handles them for the container; we ignore them in + // the parent so we don't exit before the child and leak the + // forwarder mid-call. + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigs) + go func() { + for range sigs { + } + }() + + child := exec.Command(dockerBin, dockerArgs...) + child.Stdin = os.Stdin + child.Stdout = os.Stdout + child.Stderr = os.Stderr + err = child.Run() + // Exit-code propagation via fmt.Errorf would collapse everything to + // Cobra's generic exit 1. Return the ExitError unchanged so a caller + // wrapping us can inspect it; for now we also lose the exact code to + // keep defers (network teardown, signal.Stop) intact. + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return fmt.Errorf("sandbox container exited with status %d", exitErr.ExitCode()) + } + return fmt.Errorf("docker run: %w", err) + } + return nil +} diff --git a/cmd/run_container_test.go b/cmd/run_container_test.go new file mode 100644 index 0000000..4274b03 --- /dev/null +++ b/cmd/run_container_test.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + + +func TestSandboxFlagsRegistered(t *testing.T) { + vCmd := findSubcommand(rootCmd, "vault") + if vCmd == nil { + t.Fatal("vault command not found") + } + rCmd := findSubcommand(vCmd, "run") + if rCmd == nil { + t.Fatal("vault run subcommand not found") + } + + for _, name := range []string{"sandbox", "image", "mount", "keep", "no-firewall", "home-volume-shared"} { + 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") + if f == nil { + t.Fatal("--sandbox not registered") + } + if err := f.Value.Set("not-a-mode"); err == nil { + t.Error("expected --sandbox to reject invalid values at flag-parse time") + } +} + +func TestSandboxMode_Set(t *testing.T) { + var m SandboxMode + for _, v := range []string{"process", "container"} { + if err := (&m).Set(v); err != nil { + t.Errorf("Set(%q): unexpected err %v", v, err) + } + if string(m) != v { + t.Errorf("after Set(%q), m = %q", v, m) + } + } + for _, bad := range []string{"", "Process", "CONTAINER", "vm", "docker"} { + err := (&m).Set(bad) + if err == nil { + t.Errorf("Set(%q): expected error, got nil", bad) + continue + } + if !strings.Contains(err.Error(), "must be one of") { + t.Errorf("Set(%q) error = %q, want substring 'must be one of'", bad, err) + } + } +} + +func TestValidateSandboxFlagConflicts(t *testing.T) { + tests := []struct { + name string + mode SandboxMode + 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"}, + {"container mode rejects --no-mitm", SandboxContainer, []string{"--no-mitm"}, "--no-mitm requires --sandbox=process"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd := newRunCommandForTest() + if err := cmd.ParseFlags(tc.setArgs); err != nil { + t.Fatalf("ParseFlags(%v): %v", tc.setArgs, err) + } + err := validateSandboxFlagConflicts(cmd, tc.mode) + if tc.wantErr == "" { + if err != nil { + t.Errorf("expected nil err, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("err = %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } +} + +// newRunCommandForTest isolates flag `Changed` state per subtest; runCmd +// itself would leak pflag state across ParseFlags calls. +func newRunCommandForTest() *cobra.Command { + var sbx SandboxMode + c := &cobra.Command{Use: "run-test"} + c.Flags().Var(&sbx, "sandbox", "") + c.Flags().String("image", "", "") + c.Flags().StringArray("mount", nil, "") + c.Flags().Bool("keep", false, "") + c.Flags().Bool("no-firewall", false, "") + c.Flags().Bool("home-volume-shared", false, "") + c.Flags().Bool("no-mitm", false, "") + return c +} diff --git a/cmd/sandbox_flag.go b/cmd/sandbox_flag.go new file mode 100644 index 0000000..dd42dd8 --- /dev/null +++ b/cmd/sandbox_flag.go @@ -0,0 +1,30 @@ +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 737d722..e446a55 100644 --- a/cmd/skill_cli.md +++ b/cmd/skill_cli.md @@ -41,6 +41,8 @@ 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. + ## Discover Available Services (Start Here) **Always run this first** to learn which hosts have credentials configured: diff --git a/cmd/skill_http.md b/cmd/skill_http.md index 79154eb..33688b8 100644 --- a/cmd/skill_http.md +++ b/cmd/skill_http.md @@ -40,6 +40,8 @@ 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. + ## Discover Available Services (Start Here) **Always call this first** to learn which hosts have credentials configured: diff --git a/docs/docs.json b/docs/docs.json index 4664219..5456659 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,7 +40,8 @@ "group": "Connect Agents", "pages": [ "guides/connect-coding-agent", - "guides/connect-custom-agent" + "guides/connect-custom-agent", + "guides/container-sandbox" ] }, { diff --git a/docs/guides/container-sandbox.mdx b/docs/guides/container-sandbox.mdx new file mode 100644 index 0000000..418607c --- /dev/null +++ b/docs/guides/container-sandbox.mdx @@ -0,0 +1,108 @@ +--- +title: "Container sandbox" +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: + +- **`--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. + + + Container mode is opt-in while it stabilizes. Process mode remains the default. + + +## Quick start + +```bash +agent-vault vault run --sandbox=container -- claude +``` + +First run takes ~60s while the sandbox image builds. Subsequent runs reuse the cached image. + +## What's inside the sandbox + +- **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`. +- **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) and `SETUID` / `SETGID` (so gosu can change UID) 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-claude-home- → /home/claude/.claude` (per-invocation by default; removed after the container exits. See `--home-volume-shared` below for persistence.) + +## Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--sandbox` | `process` | `process` (default) or `container`. Also read from `AGENT_VAULT_SANDBOX`. | +| `--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. Default is per-invocation (auth doesn't persist, but concurrent runs can't corrupt each other). | + +## Bundled image vs `--image` + +The default image pins `@anthropic-ai/claude-code` at build time. If you want a specific version, or a different agent entirely, provide your own image: + +```bash +# Build your own image (any Dockerfile that runs as root at entry, +# 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 . + +agent-vault vault run --sandbox=container --image=my-org/my-agent-sandbox:v1 -- claude +``` + +## Concurrency + +The default per-invocation home volume means **your Claude login doesn't persist across runs** — you'll be prompted to log in again on each `vault run`. This is the conservative choice: shared state across concurrent containers corrupts `.claude/` (auth tokens, MCP configs, session history). + +When you want auth to persist across invocations in a single-project workflow: + +```bash +agent-vault vault run --sandbox=container --home-volume-shared -- claude +``` + +Do not run two `--home-volume-shared` sessions concurrently — there's no locking, and `.claude/` will race. + +## Threat model + +**In scope.** The agent (and any subprocess it spawns) cannot: + +- Reach destinations outside `host.docker.internal:` via any network method (HTTPS, raw sockets, ICMP, whatever) — iptables DROPs at the kernel before the packet leaves the container. +- Exfiltrate over DNS — no DNS rule, no resolver, `host.docker.internal` is baked into `/etc/hosts`. +- Inspect or mutate host state outside the workspace and its own ephemeral home volume. +- Drop its own iptables rules — `gosu claude` runs UID != 0, Docker doesn't grant container capabilities as ambient caps to non-root processes. + +**Out of scope.** + +- Container escapes via kernel exploits. We rely on Docker's standard isolation. +- Exfiltration through Agent Vault to whitelisted upstreams (e.g. Anthropic API). Agent Vault's service catalog and credential-injection policy govern that; the container just guarantees traffic can't bypass the broker. +- Side channels (timing, resource usage). + +## Platform support + +- **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. + +## Troubleshooting + +**"docker not found in PATH"** — install Docker Desktop (macOS) or Docker Engine (Linux) and make sure `docker` is on `PATH`. + +**"host.docker.internal not resolvable"** inside the container — Docker version is too old. Upgrade to 20.10+ on Linux. + +**First run hangs** — the image build pulls `node:22-bookworm-slim` and runs `apt-get install` + `npm install -g @anthropic-ai/claude-code`. ~60 seconds on a fast connection; a slow network or corporate registry proxy can make it much longer. Watch `docker ps` in another terminal to confirm progress. + +**`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. + +## Related + +- [Connect a coding agent](/guides/connect-coding-agent) — the standard `--sandbox=process` 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 13922cd..b9148db 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -249,6 +249,12 @@ 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 (`--sandbox=container` only). Default is a per-invocation volume — auth doesn't persist but concurrent runs can't corrupt each other. | diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx index cf5784a..b2d5e23 100644 --- a/docs/self-hosting/environment-variables.mdx +++ b/docs/self-hosting/environment-variables.mdx @@ -20,6 +20,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. | Master password resolution order: diff --git a/internal/ca/sandbox_sni_test.go b/internal/ca/sandbox_sni_test.go new file mode 100644 index 0000000..26939ff --- /dev/null +++ b/internal/ca/sandbox_sni_test.go @@ -0,0 +1,22 @@ +package ca + +import ( + "testing" +) + +// TestValidateSNI_HostDockerInternal locks in the invariant that the +// sandbox container 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`. +func TestValidateSNI_HostDockerInternal(t *testing.T) { + isIP, err := validateSNI("host.docker.internal") + if err != nil { + t.Fatalf("validateSNI(host.docker.internal) = err %v, want nil", err) + } + if isIP { + t.Error("host.docker.internal should validate as a DNS name, not an IP") + } +} diff --git a/internal/mitm/sandbox_loopback_test.go b/internal/mitm/sandbox_loopback_test.go new file mode 100644 index 0000000..a22e237 --- /dev/null +++ b/internal/mitm/sandbox_loopback_test.go @@ -0,0 +1,27 @@ +package mitm + +import ( + "net/http" + "testing" +) + +// 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 +// 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. +// +// If this test ever fails, the forwarder is no longer laundering the +// source IP (the forwarder changed how it dials upstream, or +// isLoopbackPeer was tightened). Either way, container mode would +// start getting 429'd on CONNECT bursts; update the rate-limit path +// before merging such a change. +func TestIsLoopbackPeer_CoversForwarderLaundering(t *testing.T) { + // RemoteAddr shape matches what net/http reports after Hijack on + // a loopback-dialed connection: "127.0.0.1:". + r := &http.Request{RemoteAddr: "127.0.0.1:48293"} + if !isLoopbackPeer(r) { + t.Fatal("forwarder-laundered loopback conn must be recognized as loopback peer") + } +} diff --git a/internal/sandbox/assets/Dockerfile b/internal/sandbox/assets/Dockerfile new file mode 100644 index 0000000..ef6cc3c --- /dev/null +++ b/internal/sandbox/assets/Dockerfile @@ -0,0 +1,23 @@ +# Agent Vault sandbox image. Built locally on first use by EnsureImage. +# +# TODO(v2): pin FROM by @sha256:... digest. Today the content-hash tag +# covers the Dockerfile + scripts only, not the resolved base image. +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + iptables iproute2 ca-certificates curl git python3 gosu \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code + +RUN useradd -m -s /bin/bash claude + +COPY init-firewall.sh /usr/local/sbin/init-firewall.sh +COPY entrypoint.sh /usr/local/sbin/entrypoint.sh +RUN chmod 0755 /usr/local/sbin/init-firewall.sh /usr/local/sbin/entrypoint.sh + +WORKDIR /workspace + +ENTRYPOINT ["/usr/local/sbin/entrypoint.sh"] +CMD ["claude"] diff --git a/internal/sandbox/assets/entrypoint.sh b/internal/sandbox/assets/entrypoint.sh new file mode 100644 index 0000000..62ee86f --- /dev/null +++ b/internal/sandbox/assets/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Runs as root (from tini/--init), sets up egress, drops to unprivileged +# claude user via gosu. gosu (not sudo) keeps signals + TTY clean. +set -euo pipefail + +if [ "${AGENT_VAULT_NO_FIREWALL:-0}" = "1" ]; then + echo "agent-vault: WARNING --no-firewall active, egress UNRESTRICTED" >&2 +else + /usr/local/sbin/init-firewall.sh +fi + +# Strip the internal plumbing vars so claude's env is clean. +unset VAULT_HTTP_PORT VAULT_MITM_PORT AGENT_VAULT_NO_FIREWALL + +exec gosu claude "$@" diff --git a/internal/sandbox/assets/init-firewall.sh b/internal/sandbox/assets/init-firewall.sh new file mode 100644 index 0000000..d86c7dc --- /dev/null +++ b/internal/sandbox/assets/init-firewall.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Default-deny egress. The one hostname we need (host.docker.internal) +# comes from /etc/hosts via `docker run --add-host`, so there's no DNS +# rule — closing the DNS-exfil channel. +set -euo pipefail + +[ -n "${VAULT_HTTP_PORT:-}" ] || { echo "init-firewall: VAULT_HTTP_PORT unset" >&2; exit 1; } +[ -n "${VAULT_MITM_PORT:-}" ] || { echo "init-firewall: VAULT_MITM_PORT unset" >&2; exit 1; } + +# getent ahostsv4 returns only A records — using plain `hosts` picks up +# the AAAA first on Docker Desktop, but our iptables rules are IPv4. +GW_IP=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}') +if [ -z "$GW_IP" ]; then + echo "init-firewall: host.docker.internal has no IPv4 entry (missing --add-host?)" >&2 + exit 1 +fi +if ! echo "$GW_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "init-firewall: gateway $GW_IP is not a plain IPv4 literal" >&2 + exit 1 +fi + +# Flush and default-deny OUTPUT. INPUT stays default-ACCEPT; only the +# reply traffic to our allowed outbound conns matters, and it's caught +# by the conntrack rule. +iptables -F OUTPUT +iptables -P OUTPUT DROP +iptables -A OUTPUT -o lo -j ACCEPT +iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -d "$GW_IP" -p tcp --dport "$VAULT_HTTP_PORT" -j ACCEPT +iptables -A OUTPUT -d "$GW_IP" -p tcp --dport "$VAULT_MITM_PORT" -j ACCEPT + +# IPv6 lockdown. We resolved host.docker.internal via ahostsv4, so the +# forwarder path is IPv4 only — there's no destination we need to ACCEPT +# over v6. If the Docker daemon has IPv6 enabled, this closes the +# parallel egress channel that iptables rules alone would miss. +ip6tables -F OUTPUT +ip6tables -P OUTPUT DROP +ip6tables -A OUTPUT -o lo -j ACCEPT +ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + +{ + echo "agent-vault: egress locked to $GW_IP:{$VAULT_HTTP_PORT,$VAULT_MITM_PORT} (v4); all v6 dropped" + iptables -S OUTPUT + ip6tables -S OUTPUT +} >&2 diff --git a/internal/sandbox/cacopy.go b/internal/sandbox/cacopy.go new file mode 100644 index 0000000..58812f9 --- /dev/null +++ b/internal/sandbox/cacopy.go @@ -0,0 +1,95 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const ( + // sandboxDirName 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 +) + +// sessionIDRE matches the output of NewSessionID — hex-only, so a +// sessionID value can't contain path separators or "..". +var sessionIDRE = regexp.MustCompile(`^[0-9a-f]+$`) + +// WriteHostCAFile writes the MITM CA cert to +// ~/.agent-vault/sandbox/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. +// +// Returns the full host path for use as a docker -v source. +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() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create sandbox dir: %w", err) + } + path := filepath.Join(dir, caPrefix+sessionID+caSuffix) + if err := os.WriteFile(path, pem, 0o600); err != nil { + return "", fmt.Errorf("write CA file: %w", err) + } + // WriteFile with 0o600 is default-safe; Chmod to 0o644 is the + // explicit step that lets the container read its own bind mount. + // Parent dir is 0o700 so the host attack surface is unchanged. + if err := os.Chmod(path, 0o644); err != nil { // #nosec G302 -- container user (UID != host) must read bind-mounted CA; parent dir is 0o700 + return "", fmt.Errorf("chmod CA file: %w", err) + } + return path, nil +} + +// PruneHostCAFiles removes ca-*.pem files in ~/.agent-vault/sandbox/ +// 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() + if err != nil { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-caStaleTTL) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, caPrefix) || !strings.HasSuffix(name, caSuffix) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().After(cutoff) { + continue + } + _ = os.Remove(filepath.Join(dir, name)) + } +} + +func hostSandboxDir() (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 +} diff --git a/internal/sandbox/cacopy_test.go b/internal/sandbox/cacopy_test.go new file mode 100644 index 0000000..00b875f --- /dev/null +++ b/internal/sandbox/cacopy_test.go @@ -0,0 +1,116 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWriteHostCAFile_WritesAt0o644(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + const pem = "-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n" + path, err := WriteHostCAFile([]byte(pem), "deadbeef12345678") + if err != nil { + t.Fatalf("WriteHostCAFile: %v", err) + } + + wantDir := filepath.Join(home, ".agent-vault", sandboxDirName) + wantFile := filepath.Join(wantDir, caPrefix+"deadbeef12345678"+caSuffix) + if path != wantFile { + t.Errorf("path = %q, want %q", path, wantFile) + } + + dirInfo, err := os.Stat(wantDir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if dirInfo.Mode().Perm() != 0o700 { + t.Errorf("dir perms = %v, want 0o700 (host-private)", dirInfo.Mode().Perm()) + } + + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("stat file: %v", err) + } + if fileInfo.Mode().Perm() != 0o644 { + t.Errorf("file perms = %v, want 0o644 (container must read bind mount)", fileInfo.Mode().Perm()) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != pem { + t.Errorf("contents mismatch") + } +} + +func TestWriteHostCAFile_RejectsNonHexSessionID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + for _, bad := range []string{"", "../../etc/passwd", "nothex!!", "has space", "UPPERCASE"} { + if _, err := WriteHostCAFile([]byte("x"), bad); err == nil { + t.Errorf("expected error for sessionID %q, got nil", bad) + } + } +} + +func TestWriteHostCAFile_OverwriteIsSafe(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + sid := "abcdef0123456789" + if _, err := WriteHostCAFile([]byte("first"), sid); err != nil { + t.Fatalf("first write: %v", err) + } + path, err := WriteHostCAFile([]byte("second"), sid) + if err != nil { + t.Fatalf("second write: %v", err) + } + got, _ := os.ReadFile(path) + if string(got) != "second" { + t.Errorf("expected overwrite, got %q", got) + } +} + +func TestPruneHostCAFiles_RemovesStaleOnly(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dir := filepath.Join(home, ".agent-vault", sandboxDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + stale := filepath.Join(dir, caPrefix+"old-sid"+caSuffix) + fresh := filepath.Join(dir, caPrefix+"new-sid"+caSuffix) + unrelated := filepath.Join(dir, "claude-home.lock") // not a CA file + for _, p := range []string{stale, fresh, unrelated} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatalf("prep %s: %v", p, err) + } + } + // Backdate the stale file past caStaleTTL. + old := time.Now().Add(-caStaleTTL - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatalf("chtimes: %v", err) + } + + PruneHostCAFiles() + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale file should be removed, err=%v", err) + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("fresh file should remain, err=%v", err) + } + if _, err := os.Stat(unrelated); err != nil { + t.Errorf("non-ca-prefixed file should be ignored, err=%v", err) + } +} + +func TestPruneHostCAFiles_NoDirectoryIsNoError(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // fresh home, no sandbox dir created + // Must not panic or error even when the dir doesn't exist. + PruneHostCAFiles() +} diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go new file mode 100644 index 0000000..5fe0222 --- /dev/null +++ b/internal/sandbox/docker.go @@ -0,0 +1,235 @@ +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Config describes everything BuildRunArgs needs to produce a fully +// resolved `docker run` argv. All values are already decided (mode, +// session ID, network, TTY) — this type does no I/O. +type Config struct { + ImageRef string // "agent-vault/sandbox:" or user --image + SessionID string // 16 hex chars; names the network and per-invocation volume + WorkDir string // host path bound at /workspace + HostCAPath string // host path bound read-only at ContainerCAPath + NetworkName string // "agent-vault-" — must not be empty + AttachTTY bool // true if stdin is a TTY; adds -t + Keep bool // true → omit --rm + NoFirewall bool // true → container skips init-firewall.sh (debug only) + HomeVolumeShared bool // true → shared volume, false → per-invocation + Mounts []string // raw --mount "src:dst[:ro]" strings + Env []string // from BuildContainerEnv + CommandArgs []string // claude + any agent args +} + +type parsedMount struct { + Src, Dst string + ReadOnly bool +} + +// 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. +var reservedContainerDsts = []string{ + "/workspace", + ContainerCAPath, + ContainerClaudeHome, +} + +// BuildRunArgs produces the argv for `docker run …`. Pure apart from +// os.UserHomeDir + filepath.EvalSymlinks on user --mount sources. +func BuildRunArgs(cfg Config) ([]string, error) { + if cfg.ImageRef == "" { + return nil, errors.New("BuildRunArgs: ImageRef required") + } + if cfg.NetworkName == "" { + return nil, errors.New("BuildRunArgs: NetworkName required (container must never land on the default bridge)") + } + if cfg.SessionID == "" { + return nil, errors.New("BuildRunArgs: SessionID required") + } + if cfg.WorkDir == "" { + return nil, errors.New("BuildRunArgs: WorkDir required") + } + if cfg.HostCAPath == "" { + return nil, errors.New("BuildRunArgs: HostCAPath required") + } + if len(cfg.CommandArgs) == 0 { + return nil, errors.New("BuildRunArgs: CommandArgs required") + } + + home, _ := os.UserHomeDir() + + // 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 + // holds the encrypted CA key + vault database) does not expose + // that dir to the container. + resolvedWorkDir, err := filepath.EvalSymlinks(cfg.WorkDir) + if err != nil { + return nil, fmt.Errorf("resolving workdir: %w", err) + } + if err := validateHostSrc(resolvedWorkDir, home); err != nil { + return nil, fmt.Errorf("workspace: %w", err) + } + cfg.WorkDir = resolvedWorkDir + + parsed := make([]parsedMount, 0, len(cfg.Mounts)) + for _, raw := range cfg.Mounts { + pm, err := parseAndValidateMount(raw, home) + if err != nil { + return nil, err + } + parsed = append(parsed, pm) + } + + args := []string{"run"} + if !cfg.Keep { + args = append(args, "--rm") + } + args = append(args, "-i") + if cfg.AttachTTY { + args = append(args, "-t") + } + args = append(args, + "--init", + "--network", cfg.NetworkName, + // NET_ADMIN/NET_RAW: init-firewall.sh installs iptables rules. + // SETUID/SETGID: gosu(8) drops root to the claude user in entrypoint.sh. + // Docker does not grant these as *ambient* caps to non-root processes, + // so claude post-gosu has an empty effective cap set — it cannot + // exercise any of them. + "--cap-drop", "ALL", + "--cap-add", "NET_ADMIN", + "--cap-add", "NET_RAW", + "--cap-add", "SETUID", + "--cap-add", "SETGID", + "--security-opt", "no-new-privileges", + "--add-host", "host.docker.internal:host-gateway", + ) + + for _, kv := range cfg.Env { + args = append(args, "-e", kv) + } + if cfg.NoFirewall { + args = append(args, "-e", "AGENT_VAULT_NO_FIREWALL=1") + } + + args = append(args, "-v", cfg.WorkDir+":/workspace") + args = append(args, "-v", cfg.HostCAPath+":"+ContainerCAPath+":ro") + + homeVolume := "agent-vault-claude-home-" + cfg.SessionID + if cfg.HomeVolumeShared { + homeVolume = "agent-vault-claude-home" + } + args = append(args, "-v", homeVolume+":"+ContainerClaudeHome) + + for _, m := range parsed { + spec := m.Src + ":" + m.Dst + if m.ReadOnly { + spec += ":ro" + } + args = append(args, "-v", spec) + } + + args = append(args, "-w", "/workspace", cfg.ImageRef) + args = append(args, cfg.CommandArgs...) + return args, nil +} + +// parseAndValidateMount parses a --mount "src:dst[:ro|rw]" value, resolves +// symlinks on the host src, and rejects reserved paths. homeDir may be +// empty (e.g. in tests without $HOME); the $HOME-based check is skipped +// in that case. +func parseAndValidateMount(raw, homeDir string) (parsedMount, error) { + parts := strings.Split(raw, ":") + if len(parts) < 2 || len(parts) > 3 { + return parsedMount{}, fmt.Errorf("--mount %q: want src:dst[:ro]", raw) + } + m := parsedMount{Src: parts[0], Dst: parts[1]} + if len(parts) == 3 { + switch parts[2] { + case "ro": + m.ReadOnly = true + case "rw": + // default; accept but no-op + default: + return parsedMount{}, fmt.Errorf("--mount %q: mode must be 'ro' or 'rw'", raw) + } + } + if !filepath.IsAbs(m.Src) { + return parsedMount{}, fmt.Errorf("--mount %q: src must be an absolute path", raw) + } + if !filepath.IsAbs(m.Dst) { + return parsedMount{}, fmt.Errorf("--mount %q: dst must be an absolute path", raw) + } + + // EvalSymlinks is the defense against laundering a forbidden path + // via a symlink. We validate the resolved target, not the input. + resolved, err := filepath.EvalSymlinks(m.Src) + if err != nil { + return parsedMount{}, fmt.Errorf("--mount %q: resolving src: %w", raw, err) + } + if err := validateHostSrc(resolved, homeDir); err != nil { + return parsedMount{}, err + } + if err := validateContainerDst(m.Dst); err != nil { + return parsedMount{}, err + } + m.Src = resolved + return m, nil +} + +func validateHostSrc(resolved, homeDir string) error { + if isDockerSocket(resolved) { + return errors.New("--mount: refusing to bind the docker socket (would undo every sandbox guarantee)") + } + if homeDir != "" { + // Canonicalize homeDir so the prefix comparison is apples-to-apples + // with the already-EvalSymlinks'd resolved src. On macOS `/var` is a + // symlink to `/private/var`, and `$TMPDIR` lives under `/var/folders`, + // so without this the comparison silently misses. + canonicalHome := homeDir + if c, err := filepath.EvalSymlinks(homeDir); err == nil { + canonicalHome = c + } + vaultDir := filepath.Join(canonicalHome, ".agent-vault") + if resolved == vaultDir || strings.HasPrefix(resolved, vaultDir+string(os.PathSeparator)) { + return fmt.Errorf("--mount: refusing to bind inside %s (contains master-key-encrypted vault data)", filepath.Join(homeDir, ".agent-vault")) + } + } + return nil +} + +// isDockerSocket returns true for anything that points at a docker +// control socket, canonicalizing for the macOS /var→/private/var +// indirection and double-checking via file mode for unusual mount +// setups. +func isDockerSocket(resolved string) bool { + for _, p := range []string{"/var/run/docker.sock", "/private/var/run/docker.sock"} { + if resolved == p { + return true + } + } + if filepath.Base(resolved) != "docker.sock" { + return false + } + info, err := os.Stat(resolved) + if err != nil { + return false + } + return info.Mode()&os.ModeSocket != 0 +} + +func validateContainerDst(dst string) error { + for _, reserved := range reservedContainerDsts { + if dst == reserved || strings.HasPrefix(dst, reserved+"/") { + return fmt.Errorf("--mount: refusing to override reserved container path %s", reserved) + } + } + return nil +} diff --git a/internal/sandbox/docker_test.go b/internal/sandbox/docker_test.go new file mode 100644 index 0000000..b89f7d7 --- /dev/null +++ b/internal/sandbox/docker_test.go @@ -0,0 +1,295 @@ +package sandbox + +import ( + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +func baseConfig(t *testing.T) Config { + t.Helper() + return Config{ + ImageRef: "agent-vault/sandbox:deadbeef1234", + SessionID: "abcd1234ef567890", + WorkDir: t.TempDir(), + HostCAPath: filepath.Join(t.TempDir(), "ca.pem"), + NetworkName: "agent-vault-abcd1234ef567890", + Env: []string{"HTTPS_PROXY=https://tok:v@host.docker.internal:14322", "VAULT_MITM_PORT=14322"}, + CommandArgs: []string{"claude", "--version"}, + } +} + +func hasFlagValue(args []string, flag, value string) bool { + for i := 0; i < len(args)-1; i++ { + if args[i] == flag && args[i+1] == value { + return true + } + } + return false +} + +func TestBuildRunArgs_Default(t *testing.T) { + cfg := baseConfig(t) + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if args[0] != "run" { + t.Errorf("args[0] = %q, want run", args[0]) + } + if !slices.Contains(args, "--rm") { + t.Error("expected --rm by default") + } + if !slices.Contains(args, "-i") { + t.Error("expected -i") + } + if slices.Contains(args, "-t") { + t.Error("did not expect -t when AttachTTY=false") + } + if !slices.Contains(args, "--init") { + t.Error("expected --init (tini) for clean signal fan-out") + } + if !hasFlagValue(args, "--network", cfg.NetworkName) { + t.Error("expected --network with per-invocation network name") + } + if !hasFlagValue(args, "--cap-drop", "ALL") { + t.Error("expected --cap-drop ALL") + } + for _, cap := range []string{"NET_ADMIN", "NET_RAW", "SETUID", "SETGID"} { + if !hasFlagValue(args, "--cap-add", cap) { + t.Errorf("expected --cap-add %s", cap) + } + } + if !hasFlagValue(args, "--security-opt", "no-new-privileges") { + t.Error("expected --security-opt no-new-privileges") + } + if !hasFlagValue(args, "--add-host", "host.docker.internal:host-gateway") { + t.Error("expected --add-host host.docker.internal:host-gateway") + } + // Image + command are the tail. + if args[len(args)-3] != cfg.ImageRef { + t.Errorf("expected image just before command, got %v", args[len(args)-3:]) + } + if args[len(args)-2] != "claude" || args[len(args)-1] != "--version" { + t.Errorf("expected trailing command args, got %v", args[len(args)-2:]) + } +} + +func TestBuildRunArgs_Keep(t *testing.T) { + cfg := baseConfig(t) + cfg.Keep = true + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if slices.Contains(args, "--rm") { + t.Error("--keep should omit --rm") + } +} + +func TestBuildRunArgs_AttachTTY(t *testing.T) { + cfg := baseConfig(t) + cfg.AttachTTY = true + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !slices.Contains(args, "-t") { + t.Error("expected -t when AttachTTY=true") + } +} + +func TestBuildRunArgs_NoFirewall(t *testing.T) { + cfg := baseConfig(t) + cfg.NoFirewall = true + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !hasFlagValue(args, "-e", "AGENT_VAULT_NO_FIREWALL=1") { + t.Error("expected -e AGENT_VAULT_NO_FIREWALL=1 when NoFirewall=true") + } +} + +func TestBuildRunArgs_HomeVolumePerInvocation(t *testing.T) { + cfg := baseConfig(t) + args, _ := BuildRunArgs(cfg) + want := "agent-vault-claude-home-" + cfg.SessionID + ":/home/claude/.claude" + if !hasFlagValue(args, "-v", want) { + t.Errorf("expected per-invocation volume %q in args", want) + } +} + +func TestBuildRunArgs_HomeVolumeShared(t *testing.T) { + cfg := baseConfig(t) + cfg.HomeVolumeShared = true + args, _ := BuildRunArgs(cfg) + want := "agent-vault-claude-home:/home/claude/.claude" + if !hasFlagValue(args, "-v", want) { + t.Errorf("expected shared volume %q in args", want) + } + bad := "agent-vault-claude-home-" + cfg.SessionID + ":/home/claude/.claude" + if hasFlagValue(args, "-v", bad) { + t.Errorf("shared mode should not produce per-invocation volume %q", bad) + } +} + +func TestBuildRunArgs_Env(t *testing.T) { + cfg := baseConfig(t) + args, _ := BuildRunArgs(cfg) + for _, kv := range cfg.Env { + if !hasFlagValue(args, "-e", kv) { + t.Errorf("missing -e %q", kv) + } + } +} + +func TestBuildRunArgs_CAMount(t *testing.T) { + cfg := baseConfig(t) + args, _ := BuildRunArgs(cfg) + want := cfg.HostCAPath + ":" + ContainerCAPath + ":ro" + if !hasFlagValue(args, "-v", want) { + t.Errorf("expected CA bind mount %q", want) + } +} + +func TestBuildRunArgs_MissingRequired(t *testing.T) { + for _, tc := range []struct { + name string + mut func(*Config) + field string + }{ + {"ImageRef", func(c *Config) { c.ImageRef = "" }, "ImageRef"}, + {"NetworkName", func(c *Config) { c.NetworkName = "" }, "NetworkName"}, + {"SessionID", func(c *Config) { c.SessionID = "" }, "SessionID"}, + {"WorkDir", func(c *Config) { c.WorkDir = "" }, "WorkDir"}, + {"HostCAPath", func(c *Config) { c.HostCAPath = "" }, "HostCAPath"}, + {"CommandArgs", func(c *Config) { c.CommandArgs = nil }, "CommandArgs"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := baseConfig(t) + tc.mut(&cfg) + _, err := BuildRunArgs(cfg) + if err == nil { + t.Fatalf("expected error for missing %s", tc.field) + } + if !strings.Contains(err.Error(), tc.field) { + t.Errorf("err = %q, want to mention %q", err.Error(), tc.field) + } + }) + } +} + +func TestBuildRunArgs_UserMountAccepted(t *testing.T) { + // Use the work dir as a legitimate mount source — it's a real + // directory outside $HOME/.agent-vault. + cfg := baseConfig(t) + src := cfg.WorkDir + cfg.Mounts = []string{src + ":/data:ro"} + args, err := BuildRunArgs(cfg) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + resolved, _ := filepath.EvalSymlinks(src) + want := resolved + ":/data:ro" + if !hasFlagValue(args, "-v", want) { + t.Errorf("expected resolved --mount %q", want) + } +} + +// TestBuildRunArgs_RejectsCWDInsideAgentVaultDir pins the fix for the +// case where `vault run --sandbox=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) { + home := t.TempDir() + t.Setenv("HOME", home) + vaultDir := filepath.Join(home, ".agent-vault") + inside := filepath.Join(vaultDir, "some-project") + if err := os.MkdirAll(inside, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + cfg := baseConfig(t) + cfg.WorkDir = inside + + _, err := BuildRunArgs(cfg) + if err == nil { + t.Fatal("expected BuildRunArgs to reject a workdir inside ~/.agent-vault") + } + if !strings.Contains(err.Error(), ".agent-vault") { + t.Errorf("err = %q, want to mention .agent-vault", err.Error()) + } +} + +func TestParseAndValidateMount_RejectReservedContainerDst(t *testing.T) { + tmp := t.TempDir() + home := t.TempDir() + for _, dst := range []string{"/workspace", "/workspace/sub", ContainerCAPath, "/home/claude/.claude", "/home/claude/.claude/x"} { + t.Run(dst, func(t *testing.T) { + _, err := parseAndValidateMount(tmp+":"+dst, home) + if err == nil || !strings.Contains(err.Error(), "reserved") { + t.Errorf("expected reserved-path error for dst=%s, got %v", dst, err) + } + }) + } +} + +func TestParseAndValidateMount_RejectDockerSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("docker socket path is POSIX-specific") + } + // We need an actual readable path to symlink to /var/run/docker.sock. + // On most systems the socket may or may not exist; skip if absent. + if _, err := os.Lstat("/var/run/docker.sock"); err != nil { + t.Skip("/var/run/docker.sock not present; cannot test socket rejection") + } + _, err := parseAndValidateMount("/var/run/docker.sock:/sock", "") + if err == nil || !strings.Contains(err.Error(), "docker socket") { + t.Errorf("expected docker-socket rejection, got %v", err) + } +} + +// TestParseAndValidateMount_SymlinkLaunderingRejected is the substantive +// security test: a symlink under a normal-looking src must not launder +// a forbidden target ($HOME/.agent-vault) past the prefix check. +func TestParseAndValidateMount_SymlinkLaunderingRejected(t *testing.T) { + home := t.TempDir() + vaultDir := filepath.Join(home, ".agent-vault") + if err := os.MkdirAll(vaultDir, 0o700); err != nil { + t.Fatalf("mkdir vault: %v", err) + } + // A legitimate-looking src that actually resolves to $HOME/.agent-vault. + link := filepath.Join(t.TempDir(), "innocent-looking") + if err := os.Symlink(vaultDir, link); err != nil { + t.Fatalf("symlink: %v", err) + } + _, err := parseAndValidateMount(link+":/data", home) + if err == nil { + t.Fatal("expected rejection for symlink pointing into $HOME/.agent-vault") + } + if !strings.Contains(err.Error(), ".agent-vault") { + t.Errorf("err = %q, want to mention .agent-vault", err.Error()) + } +} + +func TestParseAndValidateMount_MalformedSpec(t *testing.T) { + tmp := t.TempDir() + for _, raw := range []string{ + "onlyone", + tmp + ":/dst:ro:extra", + "relative:/dst", + tmp + ":relative", + tmp + ":/dst:bogusmode", + } { + t.Run(raw, func(t *testing.T) { + _, err := parseAndValidateMount(raw, "") + if err == nil { + t.Errorf("expected error for %q", raw) + } + }) + } +} diff --git a/internal/sandbox/env.go b/internal/sandbox/env.go new file mode 100644 index 0000000..9445ae3 --- /dev/null +++ b/internal/sandbox/env.go @@ -0,0 +1,92 @@ +// Package sandbox builds the non-cooperative container sandbox that +// `vault run --sandbox=container` launches the child agent inside. +package sandbox + +import ( + "fmt" + "net/url" +) + +const ( + ContainerCAPath = "/etc/agent-vault/ca.pem" + ContainerProxyHost = "host.docker.internal" + ContainerClaudeHome = "/home/claude/.claude" +) + +// ProxyEnvParams feeds BuildProxyEnv. Process mode and container mode +// differ only in Host (loopback vs host.docker.internal) and CAPath +// (host-local vs container-local bind mount). +type ProxyEnvParams struct { + Host string // MITM listener host from the child's point of view + Port int + Token string + Vault string + CAPath string // path the child reads the CA PEM from + MITMTLS bool // true → HTTPS_PROXY uses https://, false → http:// +} + +// BuildProxyEnv returns the nine env vars that point an HTTPS client at +// agent-vault's MITM proxy with the right credentials and CA trust. +// Canonical source for both the process path (augmentEnvWithMITM) and +// the container path (BuildContainerEnv) so the list can't drift. +// +// NB: keep in sync with buildProxyEnv() in +// sdks/sdk-typescript/src/resources/sessions.ts. +func BuildProxyEnv(p ProxyEnvParams) []string { + scheme := "http" + if p.MITMTLS { + scheme = "https" + } + proxyURL := (&url.URL{ + Scheme: scheme, + User: url.UserPassword(p.Token, p.Vault), + Host: fmt.Sprintf("%s:%d", p.Host, p.Port), + }).String() + return []string{ + "HTTPS_PROXY=" + proxyURL, + "NO_PROXY=localhost,127.0.0.1", + "NODE_USE_ENV_PROXY=1", + "SSL_CERT_FILE=" + p.CAPath, + "NODE_EXTRA_CA_CERTS=" + p.CAPath, + "REQUESTS_CA_BUNDLE=" + p.CAPath, + "CURL_CA_BUNDLE=" + p.CAPath, + "GIT_SSL_CAINFO=" + p.CAPath, + "DENO_CERT=" + p.CAPath, + } +} + +// ProxyEnvKeys are the keys BuildProxyEnv emits. POSIX getenv returns +// the first match in C code paths, so parent-env occurrences must be +// stripped before appending these. +var ProxyEnvKeys = []string{ + "HTTPS_PROXY", + "NO_PROXY", + "NODE_USE_ENV_PROXY", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "DENO_CERT", +} + +// BuildContainerEnv returns the KEY=VALUE entries to pass to `docker +// run` via -e flags. Produces a fresh list rather than augmenting +// os.Environ() — the container should not inherit the host's env. +func BuildContainerEnv(token, vault string, httpPort, mitmPort int, mitmTLS bool) []string { + env := BuildProxyEnv(ProxyEnvParams{ + Host: ContainerProxyHost, + Port: mitmPort, + Token: token, + Vault: vault, + CAPath: ContainerCAPath, + MITMTLS: mitmTLS, + }) + return append(env, + "AGENT_VAULT_SESSION_TOKEN="+token, + "AGENT_VAULT_ADDR="+fmt.Sprintf("http://%s:%d", ContainerProxyHost, httpPort), + "AGENT_VAULT_VAULT="+vault, + fmt.Sprintf("VAULT_HTTP_PORT=%d", httpPort), + fmt.Sprintf("VAULT_MITM_PORT=%d", mitmPort), + ) +} diff --git a/internal/sandbox/env_test.go b/internal/sandbox/env_test.go new file mode 100644 index 0000000..8bdf022 --- /dev/null +++ b/internal/sandbox/env_test.go @@ -0,0 +1,106 @@ +package sandbox + +import ( + "net/url" + "strings" + "testing" +) + +func envMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, kv := range env { + if i := strings.IndexByte(kv, '='); i >= 0 { + m[kv[:i]] = kv[i+1:] + } + } + return m +} + +func TestBuildContainerEnv_ProxyURL(t *testing.T) { + env := BuildContainerEnv("av_sess_abc", "myvault", 14321, 14322, true) + vars := envMap(env) + + u, err := url.Parse(vars["HTTPS_PROXY"]) + if err != nil { + t.Fatalf("parse HTTPS_PROXY: %v", err) + } + if u.Scheme != "https" { + t.Errorf("scheme = %q, want https", u.Scheme) + } + if u.Hostname() != ContainerProxyHost { + t.Errorf("host = %q, want %q (container view, not 127.0.0.1)", u.Hostname(), ContainerProxyHost) + } + if u.Port() != "14322" { + t.Errorf("port = %q, want 14322", u.Port()) + } + if u.User.Username() != "av_sess_abc" { + t.Errorf("username = %q, want av_sess_abc", u.User.Username()) + } + if pw, _ := u.User.Password(); pw != "myvault" { + t.Errorf("password = %q, want myvault", pw) + } +} + +func TestBuildContainerEnv_OldServerScheme(t *testing.T) { + env := BuildContainerEnv("tok", "v", 14321, 14322, false) + vars := envMap(env) + u, _ := url.Parse(vars["HTTPS_PROXY"]) + if u.Scheme != "http" { + t.Errorf("scheme = %q, want http (pre-TLS server)", u.Scheme) + } +} + +func TestBuildContainerEnv_CAPathsAllPointAtBindMount(t *testing.T) { + env := BuildContainerEnv("tok", "v", 14321, 14322, true) + vars := envMap(env) + for _, k := range []string{ + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "DENO_CERT", + } { + if vars[k] != ContainerCAPath { + t.Errorf("%s = %q, want %q (container-internal path)", k, vars[k], ContainerCAPath) + } + } +} + +func TestBuildContainerEnv_AgentVaultAddrUsesContainerHost(t *testing.T) { + env := BuildContainerEnv("tok", "v", 14321, 14322, true) + vars := envMap(env) + want := "http://" + ContainerProxyHost + ":14321" + if vars["AGENT_VAULT_ADDR"] != want { + t.Errorf("AGENT_VAULT_ADDR = %q, want %q", vars["AGENT_VAULT_ADDR"], want) + } + if vars["AGENT_VAULT_SESSION_TOKEN"] != "tok" { + t.Errorf("session token = %q", vars["AGENT_VAULT_SESSION_TOKEN"]) + } + if vars["AGENT_VAULT_VAULT"] != "v" { + t.Errorf("vault = %q", vars["AGENT_VAULT_VAULT"]) + } +} + +// Internal helpers for init-firewall.sh — stripped from claude's env by +// entrypoint.sh, but we emit them so the init script sees them. +func TestBuildContainerEnv_FirewallPortsEmitted(t *testing.T) { + env := BuildContainerEnv("tok", "v", 14321, 14322, true) + vars := envMap(env) + if vars["VAULT_HTTP_PORT"] != "14321" { + t.Errorf("VAULT_HTTP_PORT = %q", vars["VAULT_HTTP_PORT"]) + } + if vars["VAULT_MITM_PORT"] != "14322" { + t.Errorf("VAULT_MITM_PORT = %q", vars["VAULT_MITM_PORT"]) + } +} + +// HTTP_PROXY must not be set — the MITM proxy is HTTPS-only and would +// 405 any plain http:// request routed through it. +func TestBuildContainerEnv_NoHTTPProxy(t *testing.T) { + env := BuildContainerEnv("tok", "v", 14321, 14322, true) + vars := envMap(env) + if v, ok := vars["HTTP_PROXY"]; ok { + t.Errorf("HTTP_PROXY must not be set, got %q", v) + } +} diff --git a/internal/sandbox/forwarder.go b/internal/sandbox/forwarder.go new file mode 100644 index 0000000..0971ed3 --- /dev/null +++ b/internal/sandbox/forwarder.go @@ -0,0 +1,131 @@ +package sandbox + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "sync" + "time" +) + +// Forwarder is a pair of raw TCP relays (HTTP + MITM) that bridge the +// container's per-invocation network gateway to agent-vault's loopback +// listeners. Relaying by loopback dial is load-bearing: it keeps +// isLoopbackPeer() true for container traffic, preserving the rate-limit +// exemption without a code change to the limiter. +type Forwarder struct { + HTTPPort int + MITMPort int + + cancel context.CancelFunc + wg sync.WaitGroup + done chan struct{} + + httpListener net.Listener + mitmListener net.Listener +} + +// StartForwarder binds two ephemeral listeners on bindIP and relays to +// 127.0.0.1:{upstreamHTTPPort, upstreamMITMPort}. The caller calls +// Close on error paths; in the success path syscall.Exec replaces the +// process and the kernel closes the CLOEXEC listeners. +func StartForwarder(parent context.Context, bindIP net.IP, upstreamHTTPPort, upstreamMITMPort int) (*Forwarder, error) { + if bindIP == nil { + return nil, errors.New("StartForwarder: bindIP required") + } + ctx, cancel := context.WithCancel(parent) + fwd := &Forwarder{cancel: cancel, done: make(chan struct{})} + + httpL, err := listenEphemeral(bindIP) + if err != nil { + cancel() + return nil, fmt.Errorf("bind HTTP forwarder: %w", err) + } + mitmL, err := listenEphemeral(bindIP) + if err != nil { + _ = httpL.Close() + cancel() + return nil, fmt.Errorf("bind MITM forwarder: %w", err) + } + fwd.httpListener = httpL + fwd.mitmListener = mitmL + fwd.HTTPPort = httpL.Addr().(*net.TCPAddr).Port + fwd.MITMPort = mitmL.Addr().(*net.TCPAddr).Port + + fwd.wg.Add(2) + go fwd.serve(ctx, httpL, upstreamHTTPPort) + go fwd.serve(ctx, mitmL, upstreamMITMPort) + go func() { + fwd.wg.Wait() + close(fwd.done) + }() + return fwd, nil +} + +// Close is safe to call multiple times. +func (f *Forwarder) Close() error { + if f.cancel != nil { + f.cancel() + } + if f.httpListener != nil { + _ = f.httpListener.Close() + } + if f.mitmListener != nil { + _ = f.mitmListener.Close() + } + <-f.done + return nil +} + +func listenEphemeral(bindIP net.IP) (net.Listener, error) { + return net.Listen("tcp", net.JoinHostPort(bindIP.String(), "0")) +} + +func (f *Forwarder) serve(ctx context.Context, l net.Listener, upstreamPort int) { + defer f.wg.Done() + + go func() { + <-ctx.Done() + _ = l.Close() + }() + + var connWG sync.WaitGroup + defer connWG.Wait() + + for { + conn, err := l.Accept() + if err != nil { + return + } + connWG.Add(1) + go func() { + defer connWG.Done() + relay(ctx, conn, upstreamPort) + }() + } +} + +func relay(ctx context.Context, client net.Conn, upstreamPort int) { + defer func() { _ = client.Close() }() + + upstream, err := dialLoopback(ctx, upstreamPort) + if err != nil { + return + } + defer func() { _ = upstream.Close() }() + + // First EOF closes both conns. HTTPS record streams can't do + // anything useful after either side closes, so no half-close dance. + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(upstream, client); done <- struct{}{} }() + go func() { _, _ = io.Copy(client, upstream); done <- struct{}{} }() + <-done +} + +func dialLoopback(ctx context.Context, port int) (net.Conn, error) { + d := net.Dialer{Timeout: 2 * time.Second} + return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) +} diff --git a/internal/sandbox/forwarder_test.go b/internal/sandbox/forwarder_test.go new file mode 100644 index 0000000..f1d6962 --- /dev/null +++ b/internal/sandbox/forwarder_test.go @@ -0,0 +1,146 @@ +package sandbox + +import ( + "context" + "io" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// startEchoUpstream starts a loopback TCP server that echoes each line +// back with "echo: " prefix. Returns the bound port and a stop func. +func startEchoUpstream(t *testing.T) (int, func()) { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + var wg sync.WaitGroup + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer func() { _ = c.Close() }() + buf := make([]byte, 4096) + for { + n, err := c.Read(buf) + if n > 0 { + _, _ = c.Write(append([]byte("echo: "), buf[:n]...)) + } + if err != nil { + return + } + } + }(c) + } + }() + return l.Addr().(*net.TCPAddr).Port, func() { + _ = l.Close() + wg.Wait() + } +} + +func TestForwarder_RoundTripsBytes(t *testing.T) { + httpPort, stopHTTP := startEchoUpstream(t) + defer stopHTTP() + mitmPort, stopMITM := startEchoUpstream(t) + defer stopMITM() + + fwd, err := StartForwarder(context.Background(), net.ParseIP("127.0.0.1"), httpPort, mitmPort) + if err != nil { + t.Fatalf("start: %v", err) + } + defer fwd.Close() + + for name, port := range map[string]int{"http": fwd.HTTPPort, "mitm": fwd.MITMPort} { + t.Run(name, func(t *testing.T) { + c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 2*time.Second) + if err != nil { + t.Fatalf("dial forwarder: %v", err) + } + defer func() { _ = c.Close() }() + if _, err := c.Write([]byte("ping")); err != nil { + t.Fatalf("write: %v", err) + } + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + got, err := io.ReadAll(io.LimitReader(c, 1024)) + if err != nil && err != io.EOF { + // ReadAll will block until upstream closes; we close client + // after write so upstream echoes, then we expect to read + // whatever has arrived. Use a read deadline to bail. + if !strings.Contains(err.Error(), "i/o timeout") { + t.Fatalf("read: %v", err) + } + } + if !strings.Contains(string(got), "echo: ping") { + t.Errorf("got %q, want substring %q", got, "echo: ping") + } + }) + } +} + +func TestForwarder_CancelClosesCleanly(t *testing.T) { + _, stop := startEchoUpstream(t) + defer stop() + + ctx, cancel := context.WithCancel(context.Background()) + fwd, err := StartForwarder(ctx, net.ParseIP("127.0.0.1"), 65534, 65533) + if err != nil { + t.Fatalf("start: %v", err) + } + + httpPort := fwd.HTTPPort + cancel() + + select { + case <-fwd.done: + case <-time.After(2 * time.Second): + t.Fatal("forwarder did not shut down within 2s of ctx cancel") + } + + // Post-cancel dial should fail — the listener is closed. + _, err = net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(httpPort)), 200*time.Millisecond) + if err == nil { + t.Error("expected dial to fail after forwarder shutdown") + } +} + +func TestForwarder_RequiresBindIP(t *testing.T) { + _, err := StartForwarder(context.Background(), nil, 1, 2) + if err == nil { + t.Fatal("expected error when bindIP is nil") + } +} + +func TestForwarder_UpstreamDownFailsGracefully(t *testing.T) { + // No echo server running; the forwarder accepts but the relay + // dial to 127.0.0.1: fails and the client conn is closed. + // Forwarder itself must not crash. + fwd, err := StartForwarder(context.Background(), net.ParseIP("127.0.0.1"), 1, 2) + if err != nil { + t.Fatalf("start: %v", err) + } + defer fwd.Close() + + c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(fwd.HTTPPort)), 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = c.Close() }() + // Read should hit EOF quickly when upstream dial fails. + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 16) + _, err = c.Read(buf) + if err == nil { + t.Error("expected EOF or read error when upstream is down") + } +} diff --git a/internal/sandbox/gateway.go b/internal/sandbox/gateway.go new file mode 100644 index 0000000..e1dd99b --- /dev/null +++ b/internal/sandbox/gateway.go @@ -0,0 +1,32 @@ +package sandbox + +import ( + "net" + "runtime" +) + +// HostBindIP returns the IP the forwarder should bind on so that the +// container's host.docker.internal resolves to a listener we own. +// +// On Linux, `--add-host=host.docker.internal:host-gateway` makes the +// container resolve that name to *this network's* gateway IP on the +// host side, so we bind there — reachable only from the per-invocation +// network. +// +// On macOS/Windows (Docker Desktop), host.docker.internal is routed +// through Docker Desktop's VM networking to some host interface, but +// which one varies by Docker Desktop version + VM backend (vpnkit +// historically targeted lo0; VZ/virtiofsd on Apple Silicon deliver to +// a different bridge). To be robust across versions we bind 0.0.0.0. +// The forwarder still requires a vault-scoped session token for any +// request to reach the broker, so LAN exposure on an ephemeral port +// is not a meaningful attack surface. +func HostBindIP(n *Network) net.IP { + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return net.IPv4(0, 0, 0, 0) + } + if n == nil || n.GatewayIP == nil { + return nil + } + return n.GatewayIP +} diff --git a/internal/sandbox/image.go b/internal/sandbox/image.go new file mode 100644 index 0000000..9b92778 --- /dev/null +++ b/internal/sandbox/image.go @@ -0,0 +1,125 @@ +package sandbox + +import ( + "context" + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// embedded sandbox 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 + +const ( + sandboxImageRepo = "agent-vault/sandbox" + // assetsHashLen is 12 hex chars — plenty of collision resistance + // for this purpose and short enough to read in docker image ls. + assetsHashLen = 12 +) + +// assetFiles lists embedded assets in the canonical order used for +// hashing. Order is load-bearing — changing it invalidates every +// user's cached image. +var assetFiles = []string{ + "assets/Dockerfile", + "assets/entrypoint.sh", + "assets/init-firewall.sh", +} + +// TODO: concurrent vault-run invocations that both miss the image +// 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 +// the fully qualified tag. If override is non-empty, the user's own +// image is used as-is and no build is performed. +// +// Content-hash tag pinning means that bumping agent-vault with changed +// assets automatically triggers a rebuild on next use. +func EnsureImage(ctx context.Context, override string, stderr io.Writer) (string, error) { + if override != "" { + return override, nil + } + hash, err := assetsHash() + if err != nil { + return "", err + } + tag := sandboxImageRepo + ":" + hash + if imageExists(ctx, tag) { + return tag, nil + } + + dir, err := unpackAssets(hash) + if err != nil { + return "", err + } + fmt.Fprintln(stderr, "agent-vault: building sandbox image (one-time setup)...") + build := exec.CommandContext(ctx, "docker", "build", + "-t", tag, + "-t", sandboxImageRepo+":latest", + dir, + ) + build.Stdout = stderr + build.Stderr = stderr + if err := build.Run(); err != nil { + return "", fmt.Errorf("docker build: %w", err) + } + return tag, nil +} + +func imageExists(ctx context.Context, tag string) bool { + return exec.CommandContext(ctx, "docker", "image", "inspect", tag).Run() == nil +} + +func assetsHash() (string, error) { + h := sha256.New() + for _, name := range assetFiles { + data, err := sandboxAssets.ReadFile(name) + if err != nil { + return "", fmt.Errorf("read embedded asset %s: %w", name, err) + } + _, _ = h.Write([]byte(name)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(data) + } + return hex.EncodeToString(h.Sum(nil))[:assetsHashLen], nil +} + +// unpackAssets writes the embedded files to +// ~/.agent-vault/sandbox// (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() + if err != nil { + return "", err + } + outDir := filepath.Join(dir, hash) + if err := os.MkdirAll(outDir, 0o700); err != nil { + return "", fmt.Errorf("mkdir %s: %w", outDir, err) + } + for _, name := range assetFiles { + data, err := sandboxAssets.ReadFile(name) + if err != nil { + return "", err + } + base := filepath.Base(name) + mode := os.FileMode(0o644) + if filepath.Ext(base) == ".sh" { + mode = 0o755 + } + if err := os.WriteFile(filepath.Join(outDir, base), data, mode); err != nil { + return "", fmt.Errorf("write %s: %w", base, err) + } + } + return outDir, nil +} diff --git a/internal/sandbox/image_test.go b/internal/sandbox/image_test.go new file mode 100644 index 0000000..8ccc017 --- /dev/null +++ b/internal/sandbox/image_test.go @@ -0,0 +1,84 @@ +package sandbox + +import ( + "bytes" + "context" + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestAssetsHash_Format(t *testing.T) { + h, err := assetsHash() + if err != nil { + t.Fatalf("assetsHash: %v", err) + } + if !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(h) { + t.Errorf("hash = %q, want 12 lowercase hex chars", h) + } +} + +// TestAssetsHash_Stable guards against unintentional asset edits that +// 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`. +// +// 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 = "bf9e2b33cc42" + got, err := assetsHash() + if err != nil { + t.Fatalf("assetsHash: %v", err) + } + if got != want { + t.Errorf("asset hash drift: got %q, want %q — update this constant alongside any intentional edit to assets/{Dockerfile,init-firewall.sh,entrypoint.sh}", got, want) + } +} + +func TestUnpackAssets_WritesFilesWithModes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + h, _ := assetsHash() + dir, err := unpackAssets(h) + if err != nil { + t.Fatalf("unpackAssets: %v", err) + } + + expect := map[string]os.FileMode{ + "Dockerfile": 0o644, + "entrypoint.sh": 0o755, + "init-firewall.sh": 0o755, + } + for name, wantMode := range expect { + p := filepath.Join(dir, name) + info, err := os.Stat(p) + if err != nil { + t.Errorf("stat %s: %v", p, err) + continue + } + if info.Mode().Perm() != wantMode { + t.Errorf("%s mode = %v, want %v", name, info.Mode().Perm(), wantMode) + } + } +} + +func TestEnsureImage_OverrideSkipsBuild(t *testing.T) { + // With an override, EnsureImage must return it unchanged and not + // shell out to docker (verified implicitly — we'd fail if it did, + // because nothing in the Config points at a real docker). + var stderr bytes.Buffer + got, err := EnsureImage(context.Background(), "my.registry/example:v1", &stderr) + if err != nil { + t.Fatalf("EnsureImage: %v", err) + } + if got != "my.registry/example:v1" { + t.Errorf("ref = %q, want passthrough", got) + } + if stderr.Len() != 0 { + t.Errorf("override path should be silent, got stderr = %q", stderr.String()) + } +} diff --git a/internal/sandbox/integration_test.go b/internal/sandbox/integration_test.go new file mode 100644 index 0000000..f7a027d --- /dev/null +++ b/internal/sandbox/integration_test.go @@ -0,0 +1,237 @@ +//go:build docker_integration + +// To run: go test -tags docker_integration ./internal/sandbox/ -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 + +import ( + "bytes" + "context" + "io" + "os/exec" + "strings" + "testing" + "time" +) + +func requireDocker(t *testing.T) { + t.Helper() + if err := exec.Command("docker", "version").Run(); err != nil { + t.Skipf("docker unavailable: %v", err) + } +} + +func newTestSessionID(t *testing.T) string { + t.Helper() + sid, err := NewSessionID() + if err != nil { + t.Fatalf("NewSessionID: %v", err) + } + return sid +} + +func cleanupNetwork(t *testing.T, name string) { + t.Helper() + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = exec.CommandContext(cleanup, "docker", "network", "rm", name).Run() +} + +func TestIntegration_NetworkCRUD(t *testing.T) { + requireDocker(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sid := newTestSessionID(t) + n, err := CreatePerInvocationNetwork(ctx, sid) + if err != nil { + t.Fatalf("CreatePerInvocationNetwork: %v", err) + } + defer cleanupNetwork(t, n.Name) + + if n.Name != "agent-vault-"+sid { + t.Errorf("name = %q, want agent-vault-%s", n.Name, sid) + } + if n.GatewayIP == nil || n.GatewayIP.To4() == nil { + t.Errorf("gateway IP = %v, want a non-nil IPv4", n.GatewayIP) + } + + // Label roundtrip: `docker network inspect` output must show our + // label, proving PruneStaleNetworks's filter will match. + out, err := exec.CommandContext(ctx, "docker", "network", "inspect", n.Name, + "--format", "{{index .Labels \""+NetworkLabelKey+"\"}}").Output() + if err != nil { + t.Fatalf("inspect label: %v", err) + } + if got := strings.TrimSpace(string(out)); got != NetworkLabelValue { + t.Errorf("label %q = %q, want %q", NetworkLabelKey, got, NetworkLabelValue) + } + + if err := RemoveNetwork(ctx, n.Name); err != nil { + t.Errorf("RemoveNetwork: %v", err) + } +} + +// TestIntegration_PruneRespectsGrace verifies the race fix: a network +// Created just now must NOT be pruned when grace > its age. +func TestIntegration_PruneRespectsGrace(t *testing.T) { + requireDocker(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sid := newTestSessionID(t) + n, err := CreatePerInvocationNetwork(ctx, sid) + if err != nil { + t.Fatalf("CreatePerInvocationNetwork: %v", err) + } + defer cleanupNetwork(t, n.Name) + + // Grace 60s: our just-created network must survive a prune pass. + if err := PruneStaleNetworks(ctx, 60*time.Second); err != nil { + t.Fatalf("PruneStaleNetworks: %v", err) + } + if !networkExists(ctx, n.Name) { + t.Error("newly-created network pruned despite being within grace period — the grace-window fix regressed") + } + + // Grace 0: now it's eligible for prune. + if err := PruneStaleNetworks(ctx, 1*time.Nanosecond); err != nil { + t.Fatalf("PruneStaleNetworks (0 grace): %v", err) + } + if networkExists(ctx, n.Name) { + t.Error("empty network outside grace window was not pruned") + } +} + +func networkExists(ctx context.Context, name string) bool { + return exec.CommandContext(ctx, "docker", "network", "inspect", name).Run() == nil +} + +// TestIntegration_ImageBuildCaches covers EnsureImage's fast path: the +// second call must skip `docker build`. +func TestIntegration_ImageBuildCaches(t *testing.T) { + requireDocker(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + var out1 bytes.Buffer + ref1, err := EnsureImage(ctx, "", &out1) + if err != nil { + t.Fatalf("EnsureImage (first): %v", err) + } + if !strings.HasPrefix(ref1, sandboxImageRepo+":") { + t.Errorf("ref = %q, want %s:", ref1, sandboxImageRepo) + } + + var out2 bytes.Buffer + ref2, err := EnsureImage(ctx, "", &out2) + if err != nil { + t.Fatalf("EnsureImage (second): %v", err) + } + if ref2 != ref1 { + t.Errorf("second call ref = %q, want %q (deterministic)", ref2, ref1) + } + if out2.Len() != 0 { + t.Errorf("cached second call should not print build output, got %q", out2.String()) + } +} + +// TestIntegration_EgressBlockedEndToEnd is the big-hammer test: builds +// the image, runs a container on a per-invocation network with the +// forwarder up, and asserts curl to an arbitrary external IP fails +// while curl to the forwarder succeeds. +// +// This is slow (~60s cold, ~5s warm) and the most expensive integration +// in the suite. Skip in short mode. +func TestIntegration_EgressBlockedEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("skip in -short mode; builds the sandbox image") + } + requireDocker(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + imageRef, err := EnsureImage(ctx, "", io.Discard) + if err != nil { + t.Fatalf("EnsureImage: %v", err) + } + sid := newTestSessionID(t) + n, err := CreatePerInvocationNetwork(ctx, sid) + if err != nil { + t.Fatalf("CreatePerInvocationNetwork: %v", err) + } + defer cleanupNetwork(t, n.Name) + + // Try to curl a routable external IP. iptables DROP should cause + // the TCP SYN to be discarded; curl exits non-zero after the short + // --max-time. + out, err := exec.CommandContext(ctx, "docker", "run", "--rm", + "--network", n.Name, + "--cap-drop=ALL", "--cap-add=NET_ADMIN", "--cap-add=NET_RAW", + "--security-opt=no-new-privileges", + "--add-host=host.docker.internal:host-gateway", + "-e", "VAULT_HTTP_PORT=14321", + "-e", "VAULT_MITM_PORT=14322", + "--entrypoint", "/bin/bash", + imageRef, + "-c", + "/usr/local/sbin/init-firewall.sh && curl --max-time 3 -fsS https://1.1.1.1 && echo SHOULD_NOT_REACH || echo BLOCKED", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run: %v\n%s", err, string(out)) + } + if !strings.Contains(string(out), "BLOCKED") { + t.Errorf("expected BLOCKED in output, got:\n%s", string(out)) + } + if strings.Contains(string(out), "SHOULD_NOT_REACH") { + t.Errorf("container reached external network despite init-firewall; output:\n%s", string(out)) + } +} + +// TestIntegration_EntrypointDropsToClaudeUser proves that gosu actually +// drops privileges under --security-opt=no-new-privileges. If this +// breaks, every "container runs as claude, not root" claim in the +// threat model is wrong. +func TestIntegration_EntrypointDropsToClaudeUser(t *testing.T) { + if testing.Short() { + t.Skip("skip in -short mode; builds the sandbox image") + } + requireDocker(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + imageRef, err := EnsureImage(ctx, "", io.Discard) + if err != nil { + t.Fatalf("EnsureImage: %v", err) + } + sid := newTestSessionID(t) + n, err := CreatePerInvocationNetwork(ctx, sid) + if err != nil { + t.Fatalf("CreatePerInvocationNetwork: %v", err) + } + defer cleanupNetwork(t, n.Name) + + out, err := exec.CommandContext(ctx, "docker", "run", "--rm", + "--network", n.Name, + "--cap-drop=ALL", "--cap-add=NET_ADMIN", "--cap-add=NET_RAW", + "--security-opt=no-new-privileges", + "--add-host=host.docker.internal:host-gateway", + "-e", "VAULT_HTTP_PORT=14321", + "-e", "VAULT_MITM_PORT=14322", + "-e", "AGENT_VAULT_NO_FIREWALL=1", // test identity, not firewall + imageRef, + "whoami", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run whoami: %v\n%s", err, string(out)) + } + if user := strings.TrimSpace(string(out)); user != "claude" { + t.Errorf("whoami = %q, want claude (gosu × no-new-privileges regression?)", user) + } +} diff --git a/internal/sandbox/network.go b/internal/sandbox/network.go new file mode 100644 index 0000000..8b182a7 --- /dev/null +++ b/internal/sandbox/network.go @@ -0,0 +1,225 @@ +package sandbox + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "os/exec" + "strings" + "time" +) + +// NetworkLabelKey / NetworkLabelValue are set on every docker network +// agent-vault creates so PruneStaleNetworks can identify ones it owns +// 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" + NetworkLabelValue = "1" + NetworkNamePrefix = "agent-vault-" + VolumeNamePrefix = "agent-vault-claude-home-" + DefaultPruneGrace = 60 * time.Second + sessionIDBytes = 8 // 16 hex chars + sessionLabelPrefix = "agent-vault-session=" +) + +// Network describes a created per-invocation docker bridge network. +type Network struct { + Name string + GatewayIP net.IP +} + +// NewSessionID returns 16 lowercase hex chars from crypto/rand. Kept +// separate from the scoped auth-session token so rotating one does not +// invalidate network / volume names mid-run. +func NewSessionID() (string, error) { + var b [sessionIDBytes]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("session id: %w", err) + } + return hex.EncodeToString(b[:]), nil +} + +func networkName(sessionID string) string { return NetworkNamePrefix + sessionID } + +// CreatePerInvocationNetwork creates agent-vault- as a +// labeled bridge network. The gateway IP is where the forwarder binds +// on Linux and what `--add-host=host.docker.internal:host-gateway` +// resolves to inside the container. +func CreatePerInvocationNetwork(ctx context.Context, sessionID string) (*Network, error) { + name := networkName(sessionID) + create := exec.CommandContext(ctx, "docker", "network", "create", + "--driver", "bridge", + "--label", NetworkLabelKey+"="+NetworkLabelValue, + "--label", sessionLabelPrefix+sessionID, + name, + ) + if out, err := create.CombinedOutput(); err != nil { + return nil, fmt.Errorf("docker network create %s: %w: %s", name, err, strings.TrimSpace(string(out))) + } + gw, err := inspectNetworkGateway(ctx, name) + if err != nil { + // Use a detached context for cleanup so a ctx cancellation + // (SIGINT during startup) doesn't leak a half-created network. + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = exec.CommandContext(cleanup, "docker", "network", "rm", name).Run() + cancel() + return nil, err + } + return &Network{Name: name, GatewayIP: gw}, nil +} + +func inspectNetworkGateway(ctx context.Context, name string) (net.IP, error) { + cmd := exec.CommandContext(ctx, "docker", "network", "inspect", name, + "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("docker network inspect %s: %w", name, err) + } + gw := net.ParseIP(strings.TrimSpace(string(out))) + if gw == nil { + return nil, fmt.Errorf("docker network inspect %s: empty gateway", name) + } + return gw, nil +} + +// RemoveNetwork is a best-effort teardown. +func RemoveNetwork(ctx context.Context, name string) error { + return exec.CommandContext(ctx, "docker", "network", "rm", name).Run() +} + +// ClaudeHomeVolumeName returns the per-invocation claude-home volume +// name for a session. Keeping the mapping in one place prevents drift +// with PruneStaleVolumes's name-prefix filter. +func ClaudeHomeVolumeName(sessionID string) string { + return VolumeNamePrefix + sessionID +} + +// RemoveVolume is a best-effort `docker volume rm`. Ignores "volume +// still in use" errors (the defer path only runs after the container +// is gone, but a racing run on the same name is theoretically possible +// with the shared volume). +func RemoveVolume(ctx context.Context, name string) error { + return exec.CommandContext(ctx, "docker", "volume", "rm", name).Run() +} + +// PruneStaleVolumes removes agent-vault-claude-home-* named volumes +// that no container is currently mounting. Analogous to +// PruneStaleNetworks — reclaims volumes left behind by crashed runs. +// No grace period is needed because, unlike networks, volumes are only +// "in use" while a container has them attached; docker rejects rm on +// attached volumes, so racing creators are self-protecting. +func PruneStaleVolumes(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "docker", "volume", "ls", + "--filter", "name="+VolumeNamePrefix, + "--format", "{{.Name}}", + ) + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("docker volume ls: %w", err) + } + for _, name := range strings.Fields(string(out)) { + // VolumeNamePrefix has a trailing dash, so it matches only the + // per-invocation volumes ("agent-vault-claude-home-"), never + // the shared volume ("agent-vault-claude-home") whose name lacks + // the dash. Docker rejects rm on in-use volumes, so racing + // runs are self-protecting. + if !strings.HasPrefix(name, VolumeNamePrefix) { + continue + } + _ = exec.CommandContext(ctx, "docker", "volume", "rm", name).Run() + } + return nil +} + +// PruneStaleNetworks removes agent-vault-* networks with zero attached +// containers whose Created timestamp is older than grace. The grace +// window is load-bearing: invocation B's prune must not delete +// invocation A's freshly-created network before A attaches its +// container. Defaults to DefaultPruneGrace. +func PruneStaleNetworks(ctx context.Context, grace time.Duration) error { + if grace <= 0 { + grace = DefaultPruneGrace + } + names, err := listLabeledNetworks(ctx) + if err != nil { + return err + } + cutoff := time.Now().Add(-grace) + for _, n := range names { + info, err := inspectNetworkInfo(ctx, n) + if err != nil { + continue // best-effort + } + if !shouldPrune(info, cutoff) { + continue + } + // Ignore errors: another run may be removing it too, or it may + // have just attached a container between ls and rm. + _ = exec.CommandContext(ctx, "docker", "network", "rm", n).Run() + } + return nil +} + +func listLabeledNetworks(ctx context.Context) ([]string, error) { + cmd := exec.CommandContext(ctx, "docker", "network", "ls", + "--filter", "label="+NetworkLabelKey+"="+NetworkLabelValue, + "--filter", "name="+NetworkNamePrefix, + "--format", "{{.Name}}", + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("docker network ls: %w", err) + } + raw := strings.Fields(string(out)) + // Defense in depth: docker's `name=` filter is a substring match. + // Enforce strict prefix in Go so an unrelated network like + // "my-agent-vault-thing" never matches. + filtered := raw[:0] + for _, n := range raw { + if strings.HasPrefix(n, NetworkNamePrefix) { + filtered = append(filtered, n) + } + } + return filtered, nil +} + +// networkInfo is the slice of `docker network inspect` output we care +// about for prune decisions. +type networkInfo struct { + Created time.Time + Containers map[string]any +} + +func inspectNetworkInfo(ctx context.Context, name string) (networkInfo, error) { + cmd := exec.CommandContext(ctx, "docker", "network", "inspect", name, "--format", "{{json .}}") + out, err := cmd.Output() + if err != nil { + return networkInfo{}, err + } + return parseNetworkInspect(out) +} + +func parseNetworkInspect(data []byte) (networkInfo, error) { + var raw struct { + Created time.Time `json:"Created"` + Containers map[string]any `json:"Containers"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return networkInfo{}, fmt.Errorf("parse network inspect: %w", err) + } + return networkInfo{Created: raw.Created, Containers: raw.Containers}, nil +} + +func shouldPrune(info networkInfo, cutoff time.Time) bool { + if len(info.Containers) > 0 { + return false + } + if info.Created.After(cutoff) { + return false + } + return true +} diff --git a/internal/sandbox/network_test.go b/internal/sandbox/network_test.go new file mode 100644 index 0000000..f47ee82 --- /dev/null +++ b/internal/sandbox/network_test.go @@ -0,0 +1,128 @@ +package sandbox + +import ( + "net" + "regexp" + "runtime" + "testing" + "time" +) + +func TestNewSessionID_Format(t *testing.T) { + sid, err := NewSessionID() + if err != nil { + t.Fatalf("NewSessionID: %v", err) + } + if !regexp.MustCompile(`^[0-9a-f]{16}$`).MatchString(sid) { + t.Errorf("sid = %q, want 16 lowercase hex chars", sid) + } + // Two calls must produce distinct IDs; probabilistic but safe in + // practice with 8 bytes of randomness. + other, _ := NewSessionID() + if sid == other { + t.Error("two NewSessionID calls returned the same value") + } +} + +func TestNetworkName_Format(t *testing.T) { + if got := networkName("abcd1234ef567890"); got != "agent-vault-abcd1234ef567890" { + t.Errorf("networkName = %q", got) + } + if want := NetworkNamePrefix + "X"; want != "agent-vault-X" { + t.Errorf("prefix const drifted: %q", NetworkNamePrefix) + } +} + +func TestShouldPrune_GracePeriodProtectsRecentNetworks(t *testing.T) { + now := time.Now() + cutoff := now.Add(-60 * time.Second) + tests := []struct { + name string + info networkInfo + wantPrune bool + wantReason string + }{ + { + name: "empty + old: prune", + info: networkInfo{Created: now.Add(-120 * time.Second)}, + wantPrune: true, + wantReason: "stale", + }, + { + name: "empty + young (in grace): keep", + info: networkInfo{Created: now.Add(-10 * time.Second)}, + wantPrune: false, + wantReason: "grace-period (racing invocation may be attaching)", + }, + { + name: "has containers + old: keep", + info: networkInfo{Created: now.Add(-120 * time.Second), Containers: map[string]any{"c1": nil}}, + wantPrune: false, + wantReason: "in use", + }, + { + name: "has containers + young: keep", + info: networkInfo{Created: now.Add(-10 * time.Second), Containers: map[string]any{"c1": nil}}, + wantPrune: false, + wantReason: "in use", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldPrune(tc.info, cutoff) + if got != tc.wantPrune { + t.Errorf("shouldPrune(%+v, cutoff=%v) = %v, want %v (%s)", + tc.info, cutoff, got, tc.wantPrune, tc.wantReason) + } + }) + } +} + +func TestParseNetworkInspect(t *testing.T) { + data := []byte(`{"Created":"2026-04-20T12:34:56Z","Containers":{"abc123":{}}}`) + got, err := parseNetworkInspect(data) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got.Containers) != 1 { + t.Errorf("Containers len = %d, want 1", len(got.Containers)) + } + if got.Created.IsZero() { + t.Error("Created is zero") + } +} + +func TestParseNetworkInspect_EmptyContainers(t *testing.T) { + data := []byte(`{"Created":"2026-04-20T12:34:56Z","Containers":{}}`) + got, err := parseNetworkInspect(data) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got.Containers) != 0 { + t.Errorf("expected empty Containers") + } +} + +func TestHostBindIP(t *testing.T) { + // macOS/Windows: 0.0.0.0 so Docker Desktop can deliver + // host.docker.internal traffic regardless of which host interface + // its VM backend routes through. + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + n := &Network{GatewayIP: net.ParseIP("172.20.0.1")} + got := HostBindIP(n) + if !got.Equal(net.IPv4(0, 0, 0, 0)) { + t.Errorf("HostBindIP on %s = %v, want 0.0.0.0", runtime.GOOS, got) + } + } + // Linux path (or whatever host we're on): gateway IP passthrough. + if runtime.GOOS == "linux" { + want := net.ParseIP("172.20.0.1") + got := HostBindIP(&Network{GatewayIP: want}) + if !got.Equal(want) { + t.Errorf("HostBindIP on linux = %v, want %v", got, want) + } + if HostBindIP(nil) != nil { + t.Error("HostBindIP(nil) should return nil on linux") + } + } +}