Credential isolation for AI agents. Agents never see real API keys — structural guarantee, not policy.
Every AI agent framework today stores API keys in environment variables or .env files. A compromised agent, malicious skill, or commodity stealer gets full access to your credentials.
~/.env → OPENAI_KEY=sk-proj-real-key # plaintext, readable by anyone
agent context → "Use OPENAI_KEY=sk-proj-real-key" # leaked into LLM context window
agent logs → Authorization: Bearer sk-proj-... # sitting in log files
Wardn vaults credentials with AES-256-GCM encryption and gives agents useless placeholder tokens. Real keys are injected at the network layer — agents never touch them.
agent environment → OPENAI_KEY=wdn_placeholder_a1b2c3d4e5f6g7h8 (useless)
wardn vault → OPENAI_KEY=sk-proj-real-key (encrypted)
agent logs → Authorization: Bearer wdn_placeholder_a1b2... (useless)
LLM context window → wdn_placeholder_a1b2c3d4e5f6g7h8 (useless)
flowchart TB
subgraph Agent["AI Agent Process"]
A1["Agent Code"]
A2["ENV: OPENAI_KEY=wdn_placeholder_a1b2..."]
end
subgraph Wardn["wardn daemon · localhost:7777"]
direction TB
P["HTTP Proxy"]
MCP["MCP Server\n(stdio)"]
subgraph Pipeline["Request Pipeline"]
direction LR
S1["Identify\nAgent"] --> S2["Resolve\nPlaceholder"] --> S3["Check\nAuth"] --> S4["Rate\nLimit"] --> S5["Inject\nReal Key"]
end
subgraph ResponsePipeline["Response Pipeline"]
direction RL
R1["Strip Real\nKeys"] --> R2["Replace with\nPlaceholders"]
end
subgraph Vault["Encrypted Vault"]
V1["AES-256-GCM"]
V2["Argon2id KDF"]
V3["Placeholder Map\nper agent × credential"]
end
end
subgraph External["External APIs"]
E1["api.openai.com"]
E2["api.anthropic.com"]
E3["..."]
end
A1 -- "placeholder token\nin headers/body" --> P
A1 -. "MCP: get_credential_ref\nlist_credentials\ncheck_rate_limit" .-> MCP
MCP -. "placeholder token\n(never real keys)" .-> A1
P --> Pipeline
Pipeline --> External
External --> ResponsePipeline
ResponsePipeline -- "response with\nplaceholders only" --> A1
Pipeline <--> Vault
ResponsePipeline <--> Vault
style Agent fill:#1a1a2e,stroke:#e94560,color:#fff
style Wardn fill:#0f3460,stroke:#16213e,color:#fff
style Pipeline fill:#16213e,stroke:#e94560,color:#fff
style ResponsePipeline fill:#16213e,stroke:#e94560,color:#fff
style Vault fill:#1a1a2e,stroke:#00d2ff,color:#fff
style External fill:#0a0a0a,stroke:#533483,color:#fff
Agent sends request with placeholder in Authorization header
│
▼
┌─────────────────────────┐
│ wardn proxy │
│ localhost:7777 │
│ │
│ 1. Identify agent │
│ 2. Resolve placeholder │
│ 3. Check authorization │
│ 4. Check rate limit │
│ 5. Inject real key │
│ 6. Forward request │
│ 7. Strip key from resp │
│ 8. Return to agent │
└─────────────────────────┘
│
▼
External API (only place real key exists in transit)
cargo install wardn# Create an encrypted vault and store your keys
wardn vault create
wardn vault set OPENAI_KEY
wardn vault set ANTHROPIC_KEY
# Set up Claude Code integration (one command)
wardn setup claude-codeThat's it. Claude Code now uses wardn's MCP server to get placeholder tokens instead of reading real keys from your environment.
- Claude Code calls
get_credential_ref→ getswdn_placeholder_a1b2...(not the real key) - Agent sends request with placeholder through wardn proxy
- Proxy swaps placeholder for real key, forwards to API
- Proxy strips real key from response before returning to agent
The real key never enters the agent's memory, logs, or LLM context window.
# Get a placeholder token (never the real key)
wardn vault get OPENAI_KEY
# → wdn_placeholder_a1b2c3d4e5f6g7h8
# List stored credentials (names only, no values)
wardn vault list
# Start the proxy
wardn serve
# Start proxy + MCP server for Claude Code / Cursor
wardn serve --mcp --agent my-agentwardn vault create # create encrypted vault
wardn vault set OPENAI_KEY # store credential (prompts for value, no echo)
wardn vault get OPENAI_KEY # get placeholder token (never the real value)
wardn vault get OPENAI_KEY --agent bot # get placeholder for specific agent
wardn vault list # list all credentials
wardn vault rotate OPENAI_KEY # rotate value, placeholders unchanged
wardn vault remove OPENAI_KEY # remove credential
# Custom vault path
wardn --vault /path/to/vault.enc vault listwardn serve # HTTP proxy on 127.0.0.1:7777
wardn serve --host 0.0.0.0 --port 8080 # custom bind address
wardn serve --config wardn.toml # load config with rate limits + ACLs
wardn serve --mcp --agent my-agent # proxy + MCP server (stdio)wardn setup claude-code # register wardn as MCP server in Claude Code
wardn setup cursor # register wardn as MCP server in Cursor
# Or manually:
claude mcp add --transport stdio --scope user wardn -- wardn serve --mcp --agent claude-code- Prompts for your vault passphrase and verifies it can open the vault
- Finds the
wardnbinary path on your system - Registers wardn as an MCP server:
- Claude Code: runs
claude mcp addwithWARDN_PASSPHRASEin the env config - Cursor: writes to
~/.cursor/mcp.jsonwith the passphrase inenv
- Claude Code: runs
- On next launch, the IDE spawns
wardn serve --mcpas a subprocess
After running setup, restart your IDE and try these prompts:
"List my wardn credentials"
→ Claude calls list_credentials, shows credential names (never values)
"Get me a reference to OPENAI_KEY"
→ Claude calls get_credential_ref, gets wdn_placeholder_... (not the real key)
"Check my rate limit for OPENAI_KEY"
→ Claude calls check_rate_limit, shows remaining quota
| Tool | What it returns | Security |
|---|---|---|
get_credential_ref |
Placeholder token (wdn_placeholder_...) |
Never the real value |
list_credentials |
Credential names + metadata | Filtered by agent's access |
check_rate_limit |
Remaining quota, retry info | Read-only |
wardn migrate --dry-run # audit Claude Code dir for exposed keys
wardn migrate --source claude-code # scan + migrate to vault
wardn migrate --source open-claw # scan OpenClaw config
wardn migrate --source directory --path ./my-proj # scan any directoryFor CI/scripts, set WARDN_PASSPHRASE and WARDN_VALUE env vars to skip interactive prompts:
WARDN_PASSPHRASE=my-pass wardn vault list
WARDN_PASSPHRASE=my-pass WARDN_VALUE=sk-proj-xxx wardn vault set OPENAI_KEYAdd to your Cargo.toml:
[dependencies]
wardn = "0.3"use wardn::{Vault, config::CredentialConfig};
// Create an encrypted vault
let vault = Vault::create("vault.enc", "my-passphrase")?;
// Store a credential
vault.set_with_config("OPENAI_KEY", "sk-proj-real-key-123", &CredentialConfig {
allowed_agents: vec!["researcher".into(), "writer".into()],
allowed_domains: vec!["api.openai.com".into()],
rate_limit: Some(RateLimitConfig { max_calls: 200, per: TimePeriod::Hour }),
})?;
// Agent gets a placeholder (not the real key)
let placeholder = vault.get_placeholder("OPENAI_KEY", "researcher")?;
// → "wdn_placeholder_a1b2c3d4e5f6g7h8"
// Rotate the real key — all placeholders keep working
vault.rotate("OPENAI_KEY", "sk-proj-new-key-456")?;use wardn::daemon::{Daemon, DaemonConfig};
let daemon = Daemon::new(vault, DaemonConfig::default());
daemon.serve_proxy().await?;use wardn::mcp::WardenMcpServer;
// Serve over stdio (for Claude Code, Cursor, etc.)
WardenMcpServer::serve_stdio(vault, rate_limiter, "agent-id".into()).await?;MCP tools exposed (read-only, no credential values ever returned):
| Tool | Description |
|---|---|
get_credential_ref |
Get your placeholder token for a credential |
list_credentials |
List credentials you're authorized to access |
check_rate_limit |
Check your remaining quota |
| Property | Guarantee |
|---|---|
| No credential in agent memory | Agent process only holds placeholder strings |
| No credential on disk in plaintext | AES-256-GCM encrypted vault with Argon2id KDF |
| No credential in logs | Only placeholders appear in any log output |
| No credential in LLM context | Placeholder injected into env, real key at network layer |
| Bounded cost exposure | Token bucket rate limits per credential per agent |
| Credential echo protection | Real keys stripped from API responses before reaching agent |
| Memory safety | SensitiveString/SensitiveBytes zeroed on drop |
| Atomic persistence | Write-tmp-then-rename prevents vault corruption |
| Attack | How wardn stops it |
|---|---|
.env credential theft |
No .env files. Keys only in encrypted vault |
Malicious skill reads $OPENAI_KEY |
Gets wdn_placeholder_... — useless |
| Stealer targets agent config | Finds only placeholder tokens |
| Prompt injection exfiltrates key | Key never in agent context window |
| Agent logs contain credentials | Logs contain only placeholder strings |
| Full agent compromise | Attacker has a useless placeholder |
| Cost runaway from looping agent | Rate limit per credential per agent |
| Tool | What it does | How wardn differs |
|---|---|---|
| Secrets managers (Vault, AWS SM, 1Password) | Secure storage + retrieval | Agent still gets the real key at runtime. Wardn ensures the agent never touches it. |
| Varlock | Schema-based .env validation + AI-safe config |
Focuses on config management and leak scanning. Wardn does runtime credential injection — the key never enters the agent process. |
| OpenRouter | API routing + key management | Trusts the client with an API key. Wardn doesn't — agent holds a useless placeholder. |
| dotenv + .gitignore | Keep secrets out of git | Keys still in memory, env vars, logs. Wardn removes them from all three. |
| Service meshes (Istio, Linkerd) | Service-to-service auth | Solve infra-level mTLS. Wardn solves agent-to-API auth where the agent itself is untrusted. |
Wardn concentrates trust in a single local process (the proxy) instead of spreading it across every plugin, tool, and LLM context window. This is a smaller attack surface, not zero attack surface:
- The proxy runs locally as a subprocess spawned by your IDE or shell — same trust level as your kernel
- The vault is encrypted at rest and only decrypted in-memory with your passphrase
- If your local machine is fully compromised, wardn can't help (nothing can)
- The placeholder token is a bearer token to the proxy — but it only works via
localhost:7777, not against real APIs, and can be rate-limited and revoked per-agent
Every credential access is logged with a unique request ID for traceability:
INFO request_id=a1b2c3 agent=claude-code method=POST domain=api.openai.com path=/v1/chat/completions proxy request received
INFO request_id=a1b2c3 agent=claude-code credential=OPENAI_KEY domain=api.openai.com credential injected
INFO request_id=a1b2c3 agent=claude-code upstream_status=200 credentials_injected=1 credentials_stripped=0 proxy request completed
Set RUST_LOG=wardn=info (or debug/trace) to control verbosity. Logs go to stderr, never stdout.
[warden]
vault_path = "~/.vibeguard/vault.enc"
[warden.credentials.OPENAI_KEY]
rate_limit = { max_calls = 200, per = "hour" }
allowed_agents = ["researcher", "writer"]
allowed_domains = ["api.openai.com"]
[warden.credentials.ANTHROPIC_KEY]
rate_limit = { max_calls = 100, per = "hour" }
allowed_agents = ["researcher"]
allowed_domains = ["api.anthropic.com"]wardn/
├── src/
│ ├── main.rs # CLI entry point (clap + tokio)
│ ├── cli/
│ │ ├── mod.rs # Clap argument definitions
│ │ ├── vault_cmd.rs # Vault subcommand handlers
│ │ ├── serve_cmd.rs # Serve subcommand handler
│ │ ├── setup_cmd.rs # Claude Code / Cursor MCP setup
│ │ └── migrate_cmd.rs # Migrate subcommand handler
│ ├── lib.rs # Public API, WardenError
│ ├── config.rs # TOML configuration parsing
│ ├── vault/
│ │ ├── mod.rs # Vault CRUD operations
│ │ ├── encryption.rs # AES-256-GCM + Argon2id + zeroize types
│ │ ├── storage.rs # On-disk format (WDNV), atomic writes
│ │ └── placeholder.rs # Token generation, per-agent isolation
│ ├── proxy/
│ │ ├── mod.rs # HTTP proxy server (axum)
│ │ ├── inject.rs # Credential injection into requests
│ │ ├── strip.rs # Credential stripping from responses
│ │ └── rate_limit.rs # Token bucket rate limiter
│ ├── mcp/
│ │ ├── mod.rs # MCP server (rmcp, stdio transport)
│ │ └── tools.rs # Tool parameter/response types
│ ├── migrate/
│ │ ├── mod.rs # Migration orchestrator + risk scoring
│ │ └── scanners/
│ │ └── credentials.rs # API key pattern scanner
│ └── daemon/
│ └── mod.rs # Daemon (proxy + MCP in single process)
└── tests/
├── cli_tests.rs # CLI integration tests
├── vault_tests.rs # Vault integration tests
└── proxy_tests.rs # Proxy integration tests
flowchart LR
subgraph Input
Pass["Passphrase"]
Salt["Random Salt\n(16 bytes)"]
Creds["Credentials\n(JSON)"]
end
subgraph KDF["Key Derivation"]
Argon["Argon2id\nm=19456 t=2 p=1"]
end
subgraph Encrypt["Encryption"]
AES["AES-256-GCM"]
Nonce["Random Nonce\n(12 bytes)"]
end
subgraph Output["WDNV File"]
direction TB
Magic["WDNV (4B)"]
Ver["Version (2B)"]
SaltOut["Salt (16B)"]
Payload["Nonce ‖ Ciphertext ‖ Tag"]
end
Pass --> Argon
Salt --> Argon
Argon -- "256-bit key" --> AES
Creds --> AES
Nonce --> AES
AES --> Payload
style Input fill:#1a1a2e,stroke:#e94560,color:#fff
style KDF fill:#16213e,stroke:#00d2ff,color:#fff
style Encrypt fill:#16213e,stroke:#00d2ff,color:#fff
style Output fill:#0f3460,stroke:#533483,color:#fff
Bytes 0-3: Magic "WDNV"
Bytes 4-5: Version (u16 LE)
Bytes 6-21: Argon2id salt (16 bytes)
Bytes 22+: AES-256-GCM encrypted payload (nonce ‖ ciphertext ‖ tag)
Wardn is the credential isolation layer of VibeGuard — a security daemon for AI agents. Other planned modules:
- Sentinel — prompt injection firewall
- CloakPipe — PII redaction middleware
- Watcher — audit log + dashboard
MIT
