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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@
# Development (optional)
# AGENT_VAULT_DEV_MODE=false # when true, allows internal/localhost hosts in proposals

# Sandbox mode for `agent-vault vault run` (optional)
# process (default, cooperative) | container (non-cooperative Docker sandbox with iptables egress lock)
# AGENT_VAULT_SANDBOX=process
# Isolation mode for `agent-vault vault run` (optional)
# host (default, cooperative — runs on the host with HTTPS_PROXY) | container (non-cooperative Docker container with iptables egress lock)
# AGENT_VAULT_ISOLATION=host

# Observability (optional)
# AGENT_VAULT_LOG_LEVEL=info # info (default) | debug — debug emits one line per proxied request (no secret values)
Expand Down
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ updates:
semver-patch-days: 5

- package-ecosystem: docker
directory: /internal/sandbox/assets
directory: /internal/isolation/assets
schedule:
interval: weekly
commit-message:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ make docker # Multi-stage Docker image; data persisted at /data/.agent-vau
- Vault role: `proxy` < `member` < `admin`. Proxy can use the proxy and raise proposals; member can manage credentials/services; admin can invite humans.
- **KEK/DEK key wrapping**: A random DEK (Data Encryption Key) encrypts credentials and the CA key at rest (AES-256-GCM). If a master password is set, Argon2id derives a KEK (Key Encryption Key) that wraps the DEK; changing the password re-wraps the DEK without re-encrypting credentials. If no password is set (passwordless mode), the DEK is stored in plaintext — suitable for PaaS deploys where volume security is the trust boundary. Login uses email+password or Google OAuth. The first user to register becomes the instance owner and is auto-granted vault admin on `default`.
- **Agent skills are the agent-facing contract.** [cmd/skill_cli.md](cmd/skill_cli.md) and [cmd/skill_http.md](cmd/skill_http.md) are embedded into the binary, installed by `vault run`, and served publicly at `/v1/skills/{cli,http}`. They are the authoritative reference for what agents can do.
- **Two sandbox modes for `vault run`** (selected via `--sandbox` or `AGENT_VAULT_SANDBOX`): `process` (default, cooperative — fork+exec with `HTTPS_PROXY` envvars) and `container` (non-cooperative — Docker container with iptables egress locked to the Agent Vault proxy). Container mode lives in [internal/sandbox/](internal/sandbox/) with an embedded Dockerfile + init-firewall.sh + entrypoint.sh, built on first use and cached by content hash.
- **Two isolation modes for `vault run`** (selected via `--isolation` or `AGENT_VAULT_ISOLATION`): `host` (default, cooperative — fork+exec on the host with `HTTPS_PROXY` envvars) and `container` (non-cooperative — Docker container with iptables egress locked to the Agent Vault proxy). Container mode lives in [internal/isolation/](internal/isolation/) with an embedded Dockerfile + init-firewall.sh + entrypoint.sh, built on first use and cached by content hash.

## Where to look for details

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,15 @@ agent-vault vault run -- opencode

The agent calls APIs normally (e.g. `fetch("https://api.github.com/...")`). Agent Vault intercepts the request, injects the credential, and forwards it upstream. The agent never sees secrets.

For **non-cooperative** sandboxing — where the child physically cannot reach anything except the Agent Vault proxy, regardless of what it tries — launch it in a Docker container with egress locked down by iptables:
For **non-cooperative** isolation — where the child physically cannot reach anything except the Agent Vault proxy, regardless of what it tries — launch it in a Docker container with egress locked down by iptables:

```bash
agent-vault run --sandbox=container --share-agent-dir -- claude
agent-vault run --isolation=container --share-agent-dir -- claude
```

`--share-agent-dir` bind-mounts your host's `~/.claude` into the container so the sandboxed agent reuses your existing login. Currently Claude-only; support for other agents is coming soon.
`--share-agent-dir` bind-mounts your host's `~/.claude` into the container so the agent reuses your existing login. Currently Claude-only; support for other agents is coming soon.

See [Container sandbox](https://docs.agent-vault.dev/guides/container-sandbox) for the threat model and flags.
See [Container isolation](https://docs.agent-vault.dev/guides/container-isolation) for the threat model and flags.

### SDK — sandboxed agents (Docker, Daytona, E2B)

Expand Down
2 changes: 1 addition & 1 deletion cmd/claude_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const keychainItemName = "Claude Code-credentials"

// populateClaudeCredentialsFromKeychain extracts the host's Claude Code
// credential from the macOS Keychain and writes it to the host's
// ~/.claude/.credentials.json so the sandbox (Linux, file-based auth)
// ~/.claude/.credentials.json so the container (Linux, file-based auth)
// picks it up through the --share-agent-dir bind mount.
//
// Only runs on macOS hosts — other OSes already store auth in the file
Expand Down
2 changes: 1 addition & 1 deletion cmd/exit_code_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import "fmt"

// ExitCodeError carries a specific process exit code through the Cobra
// RunE return path. Execute() unwraps it and calls os.Exit(Code) so
// wrapped subprocesses (e.g. the sandbox container) can propagate their
// wrapped subprocesses (e.g. the isolation container) can propagate their
// real status to the shell without losing deferred cleanups — returning
// the error lets defers inside the command body run before the process
// exits.
Expand Down
30 changes: 30 additions & 0 deletions cmd/isolation_flag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cmd

import "fmt"

// IsolationMode selects how `vault run` runs the child agent.
type IsolationMode string

const (
IsolationHost IsolationMode = "host"
IsolationContainer IsolationMode = "container"
)

func (m *IsolationMode) String() string {
if *m == "" {
return string(IsolationHost)
}
return string(*m)
}

func (m *IsolationMode) Set(v string) error {
switch IsolationMode(v) {
case IsolationHost, IsolationContainer:
*m = IsolationMode(v)
return nil
default:
return fmt.Errorf("must be one of: %s, %s", IsolationHost, IsolationContainer)
}
}

func (*IsolationMode) Type() string { return "string" }
38 changes: 19 additions & 19 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"strings"
"syscall"

"github.com/Infisical/agent-vault/internal/sandbox"
"github.com/Infisical/agent-vault/internal/isolation"
"github.com/Infisical/agent-vault/internal/session"
"github.com/Infisical/agent-vault/internal/store"
"github.com/charmbracelet/huh"
Expand Down Expand Up @@ -61,20 +61,20 @@ Example:
RunE: runCmdRunE,
}

var sbx SandboxMode
c.Flags().Var(&sbx, "sandbox", "Sandbox mode: process (default) or container")
var iso IsolationMode
c.Flags().Var(&iso, "isolation", "Isolation mode: host (default) or container")

c.Flags().String("address", "", "Agent Vault server address (defaults to session address)")
c.Flags().String("role", "", "Vault role for the agent session (proxy, member, admin; default: proxy)")
c.Flags().Int("ttl", 0, "Session TTL in seconds (300–604800; default: server default 24h)")
c.Flags().Bool("no-mitm", false, "Skip HTTPS_PROXY/CA env injection for the child (explicit /proxy only)")

c.Flags().String("image", "", "Container image override (requires --sandbox=container)")
c.Flags().StringArray("mount", nil, "Extra bind mount src:dst[:ro] (repeatable; requires --sandbox=container)")
c.Flags().Bool("keep", false, "Don't pass --rm to docker (requires --sandbox=container)")
c.Flags().Bool("no-firewall", false, "Skip iptables egress rules inside the container (requires --sandbox=container; debug only)")
c.Flags().Bool("home-volume-shared", false, "Share /home/claude/.claude across invocations (requires --sandbox=container); default is a per-invocation volume, losing auth state but avoiding concurrency corruption")
c.Flags().Bool("share-agent-dir", false, "Bind-mount the host's agent state dir (~/.claude) into the container so the sandbox reuses your host login (requires --sandbox=container; mutually exclusive with --home-volume-shared)")
c.Flags().String("image", "", "Container image override (requires --isolation=container)")
c.Flags().StringArray("mount", nil, "Extra bind mount src:dst[:ro] (repeatable; requires --isolation=container)")
c.Flags().Bool("keep", false, "Don't pass --rm to docker (requires --isolation=container)")
c.Flags().Bool("no-firewall", false, "Skip iptables egress rules inside the container (requires --isolation=container; debug only)")
c.Flags().Bool("home-volume-shared", false, "Share /home/claude/.claude across invocations (requires --isolation=container); default is a per-invocation volume, losing auth state but avoiding concurrency corruption")
c.Flags().Bool("share-agent-dir", false, "Bind-mount the host's agent state dir (~/.claude) into the container so it reuses your host login (requires --isolation=container; mutually exclusive with --home-volume-shared)")

return c
}
Expand All @@ -83,21 +83,21 @@ var runCmd = newRunCmd("agent-vault vault run")
var topRunCmd = newRunCmd("agent-vault run")

func runCmdRunE(cmd *cobra.Command, args []string) error {
// 0. Resolve sandbox mode and validate flag compatibility before any
// 0. Resolve isolation mode and validate flag compatibility before any
// network I/O — the user sees conflicts immediately, not after
// a slow session-mint round-trip.
mode := *cmd.Flags().Lookup("sandbox").Value.(*SandboxMode)
mode := *cmd.Flags().Lookup("isolation").Value.(*IsolationMode)
if mode == "" {
if v := os.Getenv("AGENT_VAULT_SANDBOX"); v != "" {
if v := os.Getenv("AGENT_VAULT_ISOLATION"); v != "" {
if err := mode.Set(v); err != nil {
return fmt.Errorf("AGENT_VAULT_SANDBOX: %w", err)
return fmt.Errorf("AGENT_VAULT_ISOLATION: %w", err)
}
}
}
if mode == "" {
mode = SandboxProcess
mode = IsolationHost
}
if err := validateSandboxFlagConflicts(cmd, mode); err != nil {
if err := validateIsolationFlagConflicts(cmd, mode); err != nil {
return err
}

Expand All @@ -121,7 +121,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error {
return err
}

if mode == SandboxContainer {
if mode == IsolationContainer {
return runContainer(cmd, args, scopedToken, addr, vault)
}

Expand Down Expand Up @@ -349,8 +349,8 @@ func fetchUserVaults(addr, token string) ([]string, error) {
// corporate HTTPS_PROXY from the parent shell would otherwise silently
// win and the MITM route would be bypassed entirely.
var mitmInjectedKeys = func() map[string]struct{} {
m := make(map[string]struct{}, len(sandbox.ProxyEnvKeys))
for _, k := range sandbox.ProxyEnvKeys {
m := make(map[string]struct{}, len(isolation.ProxyEnvKeys))
for _, k := range isolation.ProxyEnvKeys {
m[k] = struct{}{}
}
return m
Expand Down Expand Up @@ -423,7 +423,7 @@ func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]stri
}

env = stripEnvKeys(env, mitmInjectedKeys)
env = append(env, sandbox.BuildProxyEnv(sandbox.ProxyEnvParams{
env = append(env, isolation.BuildProxyEnv(isolation.ProxyEnvParams{
Host: mitmHost,
Port: port,
Token: token,
Expand Down
56 changes: 28 additions & 28 deletions cmd/run_container.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@ import (
"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/Infisical/agent-vault/internal/sandbox"
"github.com/Infisical/agent-vault/internal/isolation"
)

// containerOnlyFlags are no-ops in process mode. processOnlyFlags are
// containerOnlyFlags are no-ops in host mode. hostOnlyFlags are
// no-ops in container mode (where MITM is always on, enforced by the
// iptables lockdown). Either direction is a foot-gun if accepted
// silently — reject in both.
var (
containerOnlyFlags = []string{"image", "mount", "keep", "no-firewall", "home-volume-shared", "share-agent-dir"}
processOnlyFlags = []string{"no-mitm"}
hostOnlyFlags = []string{"no-mitm"}
)

// validateContainerFlagCombos enforces mutual-exclusion between container-mode
// flags that would otherwise both try to own /home/claude/.claude. Split from
// validateSandboxFlagConflicts because the "which mode wants which flag"
// validateIsolationFlagConflicts because the "which mode wants which flag"
// axis and the "these two flags can't coexist" axis are independent.
func validateContainerFlagCombos(cmd *cobra.Command) error {
homeShared, _ := cmd.Flags().GetBool("home-volume-shared")
Expand All @@ -42,12 +42,12 @@ func validateContainerFlagCombos(cmd *cobra.Command) error {
return nil
}

func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error {
func validateIsolationFlagConflicts(cmd *cobra.Command, mode IsolationMode) error {
var disallowed []string
var otherMode string
if mode == SandboxContainer {
disallowed = processOnlyFlags
otherMode = "process"
if mode == IsolationContainer {
disallowed = hostOnlyFlags
otherMode = "host"
} else {
disallowed = containerOnlyFlags
otherMode = "container"
Expand All @@ -57,7 +57,7 @@ func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error {
if f == nil || !f.Changed {
continue
}
return fmt.Errorf("--%s requires --sandbox=%s", name, otherMode)
return fmt.Errorf("--%s requires --isolation=%s", name, otherMode)
}
return nil
}
Expand All @@ -66,10 +66,10 @@ func validateSandboxFlagConflicts(cmd *cobra.Command, mode SandboxMode) error {
// egress locked to the agent-vault proxy via iptables.
func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault string) error {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
return fmt.Errorf("--sandbox=container: only linux and darwin are supported in v1 (got %s)", runtime.GOOS)
return fmt.Errorf("--isolation=container: only linux and darwin are supported in v1 (got %s)", runtime.GOOS)
}
if _, err := exec.LookPath("docker"); err != nil {
return errors.New("--sandbox=container: `docker` not found in PATH")
return errors.New("--isolation=container: `docker` not found in PATH")
}

// Validate flag combos + set up host-side state for --share-agent-dir
Expand Down Expand Up @@ -109,7 +109,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st
}
_ = f.Close()
// macOS stores auth in Keychain, not on disk — bridge it into
// the file Linux Claude reads inside the sandbox.
// the file Linux Claude reads inside the container.
populateClaudeCredentialsFromKeychain(hostAgentDir)
// Docker Desktop on macOS translates UIDs through its hypervisor,
// so HOST_UID remapping is Linux-only.
Expand All @@ -126,18 +126,18 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st

// Housekeeping: trim resources leaked by crashed runs before we
// create new ones. All best-effort.
sandbox.PruneHostCAFiles()
_ = sandbox.PruneStaleNetworks(ctx, sandbox.DefaultPruneGrace)
_ = sandbox.PruneStaleVolumes(ctx)
isolation.PruneHostCAFiles()
_ = isolation.PruneStaleNetworks(ctx, isolation.DefaultPruneGrace)
_ = isolation.PruneStaleVolumes(ctx)

// Pull the MITM CA from the server. Container mode always routes
// through MITM — --no-mitm is a process-mode-only escape hatch.
// through MITM — --no-mitm is a host-mode-only escape hatch.
pem, mitmPort, mitmEnabled, mitmTLS, err := fetchMITMCA(addr)
if err != nil {
return fmt.Errorf("fetch MITM CA: %w", err)
}
if !mitmEnabled {
return errors.New("--sandbox=container requires the MITM proxy; server has it disabled")
return errors.New("--isolation=container requires the MITM proxy; server has it disabled")
}
if mitmPort == 0 {
mitmPort = DefaultMITMPort
Expand All @@ -152,17 +152,17 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st
}
}

sessionID, err := sandbox.NewSessionID()
sessionID, err := isolation.NewSessionID()
if err != nil {
return err
}

hostCAPath, err := sandbox.WriteHostCAFile(pem, sessionID)
hostCAPath, err := isolation.WriteHostCAFile(pem, sessionID)
if err != nil {
return fmt.Errorf("write CA: %w", err)
}

network, err := sandbox.CreatePerInvocationNetwork(ctx, sessionID)
network, err := isolation.CreatePerInvocationNetwork(ctx, sessionID)
if err != nil {
return fmt.Errorf("create docker network: %w", err)
}
Expand All @@ -171,7 +171,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st
// cleanup exec itself.
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = sandbox.RemoveNetwork(cleanup, network.Name)
_ = isolation.RemoveNetwork(cleanup, network.Name)
}()

if !homeShared && !shareAgentDir {
Expand All @@ -182,23 +182,23 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st
// is opt-in persistent; host-bind mode never creates one.
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = sandbox.RemoveVolume(cleanup, sandbox.ClaudeHomeVolumeName(sessionID))
_ = isolation.RemoveVolume(cleanup, isolation.ClaudeHomeVolumeName(sessionID))
}()
}

bindIP := sandbox.HostBindIP(network)
bindIP := isolation.HostBindIP(network)
if bindIP == nil {
return errors.New("could not determine host bind IP for forwarder")
}

fwd, err := sandbox.StartForwarder(ctx, bindIP, upstreamHTTPPort, mitmPort)
fwd, err := isolation.StartForwarder(ctx, bindIP, upstreamHTTPPort, mitmPort)
if err != nil {
return fmt.Errorf("start forwarder: %w", err)
}
defer func() { _ = fwd.Close() }()

image, _ := cmd.Flags().GetString("image")
imageRef, err := sandbox.EnsureImage(ctx, image, os.Stderr)
imageRef, err := isolation.EnsureImage(ctx, image, os.Stderr)
if err != nil {
return err
}
Expand All @@ -208,13 +208,13 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st
return fmt.Errorf("getwd: %w", err)
}

env := sandbox.BuildContainerEnv(scopedToken, vault, fwd.HTTPPort, fwd.MITMPort, mitmTLS)
env := isolation.BuildContainerEnv(scopedToken, vault, fwd.HTTPPort, fwd.MITMPort, mitmTLS)

mounts, _ := cmd.Flags().GetStringArray("mount")
keep, _ := cmd.Flags().GetBool("keep")
noFirewall, _ := cmd.Flags().GetBool("no-firewall")

dockerArgs, err := sandbox.BuildRunArgs(sandbox.Config{
dockerArgs, err := isolation.BuildRunArgs(isolation.Config{
ImageRef: imageRef,
SessionID: sessionID,
WorkDir: workDir,
Expand Down Expand Up @@ -245,7 +245,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st
}
fmt.Fprintf(os.Stderr, "%s routing container HTTPS through MITM on %s:%d (container view: host.docker.internal:%d)\n",
successText("agent-vault:"), bindIP, fwd.MITMPort, fwd.MITMPort)
fmt.Fprintf(os.Stderr, "%s starting %s in sandbox (%s)...\n\n",
fmt.Fprintf(os.Stderr, "%s starting %s with isolation=container (%s)...\n\n",
successText("agent-vault:"), boldText(args[0]), network.Name)

// Fork docker (instead of syscall.Exec) so the forwarder stays
Expand Down
Loading