Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions cmd/claude_credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)

// keychainItemName is the Keychain generic-password service name that
// Claude Code writes its OAuth credential under on macOS. Linux and
// Windows build the credentials file directly; only macOS needs bridging.
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)
// picks it up through the --share-agent-dir bind mount.
//
// Only runs on macOS hosts — other OSes already store auth in the file
// the bind mount carries through. If the file already exists it's left
// alone; the container may have refreshed it more recently than the
// Keychain and we don't want to clobber a newer token (users can `rm`
// the file to force re-extraction).
//
// Best-effort: any failure (no keychain entry, user denies the prompt,
// security binary missing) is silently swallowed so the container still
// starts and the user can /login from inside.
func populateClaudeCredentialsFromKeychain(hostAgentDir string) {
if runtime.GOOS != "darwin" {
return
}
credPath := filepath.Join(hostAgentDir, ".credentials.json")
if _, err := os.Stat(credPath); err == nil {
return
}
out, err := exec.Command("security", "find-generic-password", "-s", keychainItemName, "-w").Output()
if err != nil {
return
}
token := strings.TrimSpace(string(out))
if token == "" {
return
}
_ = os.WriteFile(credPath, []byte(token), 0o600)
}
95 changes: 56 additions & 39 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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] -- <command> [args...]",
Short: "Wrap an agent process with Agent Vault access",
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -440,5 +449,13 @@ 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")
runCmd.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)")

vaultCmd.AddCommand(runCmd)
}
Loading