Your agents run free. Your secrets stay put.
AI coding agents have your shell, your filesystem, your API keys, and full network access. That's fine until:
- A prompt injection hidden in a dependency's README or issue comment tells the agent to exfiltrate your API keys
- The agent modifies
~/.ssh/authorized_keys,~/.gitconfig, or installs packages on your host - Malicious instructions in code the agent reads make it open connections to hosts you never approved
These aren't hypothetical. Every file the agent reads is a potential injection vector, and LLM prompt injection is an open problem with no general solution.
Redan puts the agent in a microVM that boots in under a second. The agent gets a real dev environment, but it's isolated from your host. Own filesystem, own network, only the hosts you allow. Your API keys never enter the VM. Redan injects them at the network layer, only for the hosts you permit. Even if the agent is compromised by a prompt injection, it has no secret to steal and nowhere to send it.
Redan uses libkrun microVMs (sub-second boot) and a userspace TCP/IP stack to sandbox AI agents. The agent gets a full Linux environment with your project files mounted in, but runs in its own VM with its own filesystem and a network proxy that enforces a host allowlist. Secrets are injected into HTTPS request headers by the proxy on the host side, so they never exist inside the VM.
Works with Claude Code, Codex, Copilot, Cursor, or any command-line AI agent.
redan (/ɹɪˈdan/): a V-shaped fieldwork forming a salient angle toward the enemy.
Redan requires Linux with KVM (/dev/kvm) and libkrun.
pacman -S libkrundnf install libkrun-devellibkrun is not yet in the Ubuntu/Debian repositories. Build from source:
apt install build-essential libkrunfw-dev
git clone https://github.com/containers/libkrun
cd libkrun && make && sudo make installPre-built binaries for Linux x86_64 and aarch64 are available on the
releases page. Download,
extract, and move to your $PATH:
tar xzf redan-*.tar.gz
sudo mv redan /usr/local/bin/Or build from source (requires Rust 1.85+, edition 2024):
cargo install --git https://github.com/getredan/redan.gitVerify everything works:
redan doctorSet your API key and run:
export ANTHROPIC_API_KEY=sk-ant-...
cd ~/my-project
redan execThat's it. Redan auto-detects Claude Code: picks up your API key,
builds the claude-code image if needed, mounts the current directory,
allows Anthropic's API hosts, and drops you into an interactive session.
It prints what it chose so nothing is silent:
Using image: claude-code
Injecting ANTHROPIC_API_KEY for api.anthropic.com
Mounting current directory → /workspace
Auto-detect kicks in when there's no redan.toml and no explicit CLI
flags. Git remote hosts are added to the network allowlist so git works
out of the box. Host files like ~/.ssh and ~/.gitconfig are not
mounted by default (they'd be writable by the guest). Add them via
redan.toml if you need them -- redan init generates commented-out
examples.
For repeatable setups, use redan init:
redan init --claude
redan image import myproject --devcontainer .devcontainer/redan
redan execredan init --claude generates a devcontainer with Claude Code, Node.js,
and project-appropriate tooling (Python/uv, Rust, Go, etc.) plus a
redan.toml with Anthropic API hosts pre-configured.
redan image import claude-code --dockerfile dockerfiles/claude-code.dockerfile
redan exec --image claude-code -i \
--secret "ANTHROPIC_API_KEY=sk-ant-...:api.anthropic.com" \
--mount ./my-projectUse redan.toml instead of long CLI invocations. redan init generates
one from project detection:
redan init # detect project type, generate config
redan init --claude # generate config + devcontainer for Claude CodeExample redan.toml:
image = "myproject"
command = "claude --dangerously-skip-permissions"
interactive = true
[network]
allow = ["api.anthropic.com", "pypi.org"]
[secrets.ANTHROPIC_API_KEY]
value = "sk-ant-..."
hosts = ["api.anthropic.com"]
[mount.workspace]
source = "."
target = "/workspace"
[env]
CLAUDE_CONFIG_DIR = "/workspace/.claude"Looks for redan.toml in the current directory, then
~/.config/redan/config.toml. CLI flags override file values.
Redan also reads sandbox.network.allowedDomains from Claude Code
settings (.claude/settings.json and ~/.claude/settings.json) and
merges them into the network allowlist automatically.
export ANTHROPIC_API_KEY=sk-ant-...
redan execThe agent sees $ANTHROPIC_API_KEY as a placeholder token. The proxy
injects the real key only in HTTPS requests to api.anthropic.com.
Responses are scrubbed of the real value before the agent sees them.
redan exec -d --name my-agent
redan logs my-agent -f # tail the logs
redan stop my-agent # stop when doneredan exec --image claude-code \
--secret "ANTHROPIC_API_KEY=sk-ant-...:api.anthropic.com" \
--mount ./my-project \
-- claude --print "review this project"Note: --secret literal values are visible in process listings (ps).
For production use, prefer env://, vault://, or --secret-file,
which keep secrets out of process listings by having redan read them
internally.
redan doctor # system checks
redan doctor --image myimage # check a specific image exists
redan doctor --secret "KEY=val:host" # validate secret syntax + providersChecks for /dev/kvm, libkrun, libkrunfw, available images, and
whether ANTHROPIC_API_KEY is set (for zero-config Claude Code).
Guest VM (libkrun, <1s boot)
|
| virtio-fs (project dir read-write)
| virtio-net (ethernet frames over unix socket)
v
smoltcp (userspace TCP/IP on host)
|
|-- UDP :53 -> synthetic DNS (per-host IP allocation)
|-- TCP :22 -> transparent relay (allowlist checked, no injection)
|-- TCP :80 -> rejected (HTTPS only)
|-- TCP :443 -> TLS MITM proxy
|
|-- SNI extraction
|-- ephemeral cert (signed by per-session CA)
|-- secret injection (headers only, host-allowlisted)
|-- request forwarded to real upstream
|-- response scrubbed of secret values
|-- streamed back to guest
Key properties:
- Guest never sees real secret values (only placeholders)
- Secrets are injected only for explicitly allowed hosts
- Injection is restricted to HTTP headers (not URLs, not bodies)
- DNS is synthetic -- each hostname gets a unique IP, no queries leave the host
- All traffic routes through the gateway IP (no direct IP access)
- Default-deny outbound networking
- Response scrubbing is best-effort (literal byte match)
Format: ENV_VAR=value:allowed_host1,allowed_host2
The value can be a literal or a provider URI:
# Literal value
--secret "GITHUB_TOKEN=ghp_abc123:api.github.com"
# From host environment variable
--secret "GITHUB_TOKEN=env://GITHUB_TOKEN:api.github.com"
# From HashiCorp Vault (KV v2)
--secret "GITHUB_TOKEN=vault://secret/myapp#github_token:api.github.com"
# Multiple hosts
--secret "API_KEY=sk-abc:api.example.com,cdn.example.com"
# From a file (one spec per line, # comments)
--secret-file .redan-secrets
# Multiple secrets
--secret "GITHUB_TOKEN=env://GITHUB_TOKEN:api.github.com" \
--secret "NPM_TOKEN=vault://secret/myapp#npm_token:registry.npmjs.org"In redan.toml:
[secrets.GITHUB_TOKEN]
value = "env://GITHUB_TOKEN"
hosts = ["api.github.com"]env://VAR_NAME reads the secret from a host environment variable at
session start. The variable is read on the host and never passed into
the VM.
export GITHUB_TOKEN=ghp_abc123
redan exec --secret "GITHUB_TOKEN=env://GITHUB_TOKEN:api.github.com"Fails loudly if the variable is not set or is empty.
Redan reads secrets from HashiCorp Vault KV v2 via vault://path#field.
Configure with standard Vault environment variables:
export VAULT_ADDR='https://vault.example.com:8200'
export VAULT_TOKEN='hvs.xxx'
redan exec --image claude-code \
--secret "API_KEY=vault://myapp/prod#api_key:api.example.com"Falls back to ~/.vault-token if VAULT_TOKEN is not set.
Secret resolution is pluggable via the SecretProvider trait. The open
core ships with Literal, Env, and Vault providers. The trait is
designed for extension -- additional backends (AWS Secrets Manager,
1Password, etc.) can be added in the future.
The value can contain colons (splits on the last :). The guest
receives a redan_ph_<name>_<hex> placeholder via environment variable.
# Mount to /workspace (default)
--mount /home/chris/project
# Mount to specific guest path
--mount /home/chris/project:/codeUses virtio-fs for host directory sharing. The guest has read-write access, and git is your safety net for recovering from unwanted changes.
redan image create myimage --packages "python3 pip" --run "pip install flask"
redan image import myimage --from ubuntu:24.04
redan image import myimage --dockerfile path/to/Dockerfile
redan image import myimage --devcontainer .devcontainer
redan image list
redan image update myimage
redan image remove myimagecreate builds Alpine-based images with apk packages. import pulls
from Docker images, builds from Dockerfiles, or reads devcontainer
configs (build.dockerfile, image, and dockerComposeFile are all
supported).
Images are rootfs directories stored at ~/.local/share/redan/images/
and base tarballs are cached at ~/.cache/redan/.
Redan tracks when images were built and from what source. redan image list shows the age of each image, redan doctor warns about images
older than 30 days, and redan exec prints a non-blocking warning
before launching.
redan image update claude-code # rebuild from the original Dockerfileredan image update remembers how the image was built (Dockerfile,
Docker image, devcontainer, or create args) and rebuilds from
the same source.
Redan defaults to deny-all outbound networking. You must explicitly allow hosts:
[network]
allow = ["api.anthropic.com", "registry.npmjs.org"]Or on the CLI:
redan exec --allow-host api.anthropic.com --allow-host registry.npmjs.orgWildcard patterns are supported: "*.amazonaws.com" matches any
subdomain. Hosts required by secrets are automatically included.
Use "*" to allow all outbound connections (not recommended).
Connections to private IP ranges (RFC 1918, link-local, cloud metadata
endpoints) are blocked by default, even in allow-all mode. Hosts
explicitly in the allowlist may resolve to private IPs -- add
"localhost" if you need local services.
Domain fronting is blocked: requests where the HTTP Host header doesn't match the TLS SNI hostname are rejected (HTTP 421).
Don't know what hosts your agent needs? Run once in discover mode:
redan exec --image myimage --discover -- claude --print "build this project"Redan allows all connections and prints the observed hosts at exit:
--- discovered hosts ---
The agent connected to these hosts:
api.anthropic.com
registry.npmjs.org
Suggested redan.toml:
[network]
allow = [
"api.anthropic.com",
"registry.npmjs.org",
]
Copy the output into your redan.toml and subsequent runs enforce it.
Each redan exec creates a session with a unique ID. Session metadata,
logs, and audit events are stored at ~/.local/state/redan/sessions/.
Run agents in the background:
redan exec -d # detach, auto-generated ID
redan exec -d --name my-agent # detach with a nameredan sessions # list all sessions
redan sessions show <id> # session details
redan sessions remove # remove all exited sessions
redan sessions remove <id> # remove a specific sessionredan attach # attach to most recent running session
redan attach my-agent # attach by name or ID prefix
redan stop # stop most recent running session
redan stop my-agent # stop by name or ID prefixredan stop sends SIGTERM, waits 3 seconds, then SIGKILL.
redan logs # logs from most recent session
redan logs -f # tail -f
redan logs my-agent # logs by name or session IDredan exec --audit-log events.jsonl ...Structured JSON-lines event log for security audit and debugging:
{"ts":"...","event":"connect","host":"api.github.com"}
{"ts":"...","event":"inject","host":"api.github.com","env":"API_KEY"}
{"ts":"...","event":"scrub","host":"api.github.com"}
{"ts":"...","event":"reject","host":"evil.com","reason":"not_allowed"}Audit logs are also stored per-session automatically.
Redan exposes network policy to the guest so AI agents can understand their environment instead of guessing "the internet is broken":
Environment variables:
REDAN=1-- running inside a redan sandboxREDAN_NETWORK=restrict|deny-all|allow-all-- policy modeREDAN_ALLOWED_HOSTS=host1,host2,...-- permitted outbound hosts
Policy file: /etc/redan/policy -- human-readable description of
what's allowed and what's blocked.
Agents that check $REDAN can adapt their behavior: skip web searches,
avoid fetching URLs outside the allowlist, and give users clear error
messages instead of "connection failed".
Redan's threat model: a compromised or malicious AI agent running inside the VM tries to exfiltrate secrets or access unauthorized resources.
What redan prevents:
- Agent reading real secret values from environment
- Agent sending secrets to unauthorized hosts
- Agent making DNS queries to the internet
- Agent connecting directly to IP addresses (all traffic goes through proxy)
- Agent reaching hosts not in the allowlist (default-deny networking)
Known limitations:
- Scrubbing doesn't catch encoded secrets (base64, URL-encoding, etc.)
- No HTTP request body inspection (secrets in headers only)
Primary defense is the host allowlist, not scrubbing. Scrubbing reduces accidental exposure; it's not a hard security boundary.
See docs/security-model.md for the full threat model, side-channel analysis, and known limitations.
Be aware of what redan does and doesn't support before deploying it.
Authorization: Bearer <token>-- standard API key injection- Custom headers (
X-Api-Key, etc.) -- any header-based auth - Multiple secrets to multiple hosts in one session
These patterns require secrets in places redan currently can't inject:
- OAuth flows -- token exchanges happen in request/response bodies
- AWS SigV4 -- request signing requires the secret at the call site to compute HMAC over headers and body
- Client certificates -- mTLS requires the cert in the TLS handshake, which redan terminates
- Body-embedded tokens -- GraphQL variables, form fields, JSON bodies carrying auth tokens
- Cookie-based auth --
Set-Cookiefrom login flows, session tokens
If your API requires one of these, redan can still provide VM isolation and network restriction, but can't inject or scrub the credential. Pass it via environment variable (the guest sees the real value).
- HTTP/1.1 only. Redan forces ALPN to
http/1.1. All major APIs (Anthropic, OpenAI, GitHub, npm) support HTTP/1.1. If a server requires HTTP/2, it won't work through redan. - No WebSocket. Upgrade requests return 501. Binary framing after the upgrade would bypass scrubbing.
- SSH is transparent. Port 22 connections are forwarded as-is to the real upstream server. No secret injection or scrubbing -- SSH handles its own authentication. The allowlist still applies.
- No other raw TCP/UDP. Database connections, gRPC (over H2), and other protocols are not currently supported.
- HTTPS only. Plain HTTP on port 80 is rejected.
- Linux only. Requires KVM (
/dev/kvm). libkrun supports macOS via Hypervisor.framework but this is untested. - x86_64 and aarch64. Other architectures are untested.
- No Windows. WSL2 with KVM passthrough may work but is untested.
Alpha. The full chain works end-to-end: redan init --claude through
interactive Claude Code sessions with network policy enforcement.
Pre-built binaries for Linux x86_64 and aarch64 on
GitHub Releases.
This code has not been through an independent security audit. Use at your own risk and report vulnerabilities responsibly.
If redan is useful to you, consider buying me a coffee.
