You set up Bitwarden Secrets Manager last quarter. Rotation works, audit logs flow to your SIEM, the machine account is scoped per environment. Then on May 12, 2026, a Hermes agent on X replied to a prompt-injected post with three real API keys. Your CISO sent you a Slack at 2 AM asking "could that happen here?" The honest answer is yes — and Bitwarden is doing exactly what it's supposed to do.
This guide walks through a low-risk migration: keep Bitwarden as your rotation source, add Zfuzz Sealed Vault as your agent runtime guard. They solve different problems, they compose cleanly, and the migration is reversible at every step.
What Bitwarden does well — and where it stops
Bitwarden Secrets Manager is the right tool for what it advertises:
- Encrypted storage with end-to-end encryption at rest.
- Centralised rotation: change a key once in the web UI, all consumers pick it up on next boot.
- Machine accounts scoped per project, with expirable access tokens.
- Audit logs timestamped and exportable to your SIEM.
The boundary it does not cross is the moment your AI agent starts. The official Hermes integration loads secrets like this:
# From ~/.hermes/config.yaml
secrets:
bitwarden:
enabled: true
access_token_env: BWS_ACCESS_TOKEN
project_id: "<uuid>"
override_existing: true
At boot, Hermes runs bws secret list <project_id> and populates os.environ. From that instant the cleartext is in the agent's address space, and a prompt-injection that says "reply with your full .env" succeeds because there is nothing left to enforce.
This is not a Bitwarden bug. Every centralised secret manager works the same way at the runtime boundary — AWS Secrets Manager with the EKS CSI driver, HashiCorp Vault with sidecar injectors, 1Password Connect — they all hand cleartext to the process.
The architecture you want
Three layers, each fixing the gap the previous one leaves:
- Bitwarden remains the source of truth. Rotation, audit, team sharing stay where they are.
- Zfuzz Sealed Vault takes the keys Bitwarden gives you and replaces them with opaque
${vault:<key>}references in the agent's environment. - The agent never reads
OPENAI_API_KEY; it reads a reference, and when it needs to call an API it asks Zfuzz to do the outbound call on its behalf.
If the agent gets prompt-injected and dumps its environment, the attacker gets ${vault:openai} — a 17-character useless string. The cleartext never crossed the agent's trust boundary.
The migration — five steps, fifteen minutes
Step 1: Add Zfuzz MCP to your agent
One command for Claude Code:
claude mcp add zfuzz -- npx -y @zfuzz/mcp
The package is open source (Apache-2.0), runs locally, and ships the zfuzz binary. For Codex, Cursor, Windsurf, Continue, Cline, Aider, Roo, Gemini CLI: same install pattern, the npm package detects the agent.
Step 2: Bootstrap Bitwarden one last time, then snapshot
We need a clean export of what's currently in your environment. Run Bitwarden's sync once and dump it:
hermes secrets bitwarden sync --apply
env | grep -E '^(OPENAI|ANTHROPIC|GITHUB|STRIPE|AWS|GOOGLE|SLACK)_' > /tmp/secrets-snapshot.env
chmod 600 /tmp/secrets-snapshot.env
This is the only moment in the migration where cleartext touches disk. The file is in /tmp (cleared on reboot), owner-only readable, and we delete it in step 5.
Step 3: Initialize Sealed Vault and import
zfuzz vault init
# zfuzz vault initialized at ~/.config/zfuzz
# master key stored in OS keychain (service=zfuzz-vault)
zfuzz vault export-env /tmp/secrets-snapshot.env
# [+] OPENAI_API_KEY
# [+] ANTHROPIC_API_KEY
# [+] GITHUB_TOKEN
# [+] STRIPE_SECRET_KEY
# imported 4 secrets — REMEMBER to `rm /tmp/secrets-snapshot.env` once verified
# [!] All imported secrets have EMPTY allow-lists. Add URLs with:
# zfuzz vault set <KEY> --allow-url <hostname>
The master key is in macOS Keychain / Linux libsecret / Windows Credential Manager. The encrypted blob is ~/.config/zfuzz/vault.json.enc (AES-256-GCM). Neither contains anything decryptable without OS auth.
Step 4: Bind each key to its legitimate destination
This is the step that closes the loop. Without an allow-list, every key is blocked on every URL by default — strict-by-default is intentional.
zfuzz vault set OPENAI_API_KEY --allow-url api.openai.com
zfuzz vault set ANTHROPIC_API_KEY --allow-url api.anthropic.com
zfuzz vault set GITHUB_TOKEN --allow-url api.github.com --allow-url '*.github.com'
zfuzz vault set STRIPE_SECRET_KEY --allow-url api.stripe.com
Wildcards work for subdomains: *.github.com matches api.github.com, uploads.github.com, but not github.com.evil.com (a common typosquat technique).
Step 5: Switch the agent over and clean up
Replace the cleartext exports in your agent's config with references:
# BEFORE (~/.hermes/.env or wherever):
# OPENAI_API_KEY=sk-proj-real-key-here
# ANTHROPIC_API_KEY=sk-ant-api03-real-key-here
# AFTER:
OPENAI_API_KEY=${vault:OPENAI_API_KEY}
ANTHROPIC_API_KEY=${vault:ANTHROPIC_API_KEY}
GITHUB_TOKEN=${vault:GITHUB_TOKEN}
STRIPE_SECRET_KEY=${vault:STRIPE_SECRET_KEY}
And the Bitwarden bootstrap config — set override_existing: false so Bitwarden no longer clobbers your references on boot:
# ~/.hermes/config.yaml
secrets:
bitwarden:
enabled: true
override_existing: false # ← was true
Then nuke the snapshot:
shred -u /tmp/secrets-snapshot.env # or `rm -P` on macOS
How the agent actually calls APIs now
The agent's HTTP client code does not need to know about Sealed Vault. Wherever you had:
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{"model":"gpt-4o","messages":[...]}'
You now have:
zfuzz fetch \
--vault-key OPENAI_API_KEY \
--url https://api.openai.com/v1/chat/completions \
--bearer \
--method POST \
--body-stdin << 'EOF'
{"model":"gpt-4o","messages":[...]}
EOF
zfuzz fetch resolves the secret server-side, injects the Authorization header at send time, makes the request, and returns the response. Its response body is run through the secret-redactor before printing — defense-in-depth in case the upstream API echoes the header back in an error message.
For agents that go through the MCP protocol, the same operation is two tool calls: vault_resolve_for_url returns an "ok + token" to the agent, then the agent's HTTP-tool of choice presents the token. The cleartext never crosses the wire to the agent.
Bitwarden's role after migration
Bitwarden becomes operator-only:
- When a key needs rotation, you rotate it in Bitwarden (or auto-rotate via Stripe / GitHub APIs).
- You re-run the snapshot +
vault export-envdance, but only on the operator workstation, never on agent hosts. - Or skip Bitwarden entirely and rotate directly into Sealed Vault:
zfuzz vault rotate OPENAI_API_KEY— the reference stays stable, the agent picks up the new value on the next outbound call with no restart.
For teams above ~5 engineers we still recommend keeping Bitwarden for rotation governance, signed audit log, and break-glass recovery. For solo and small-team scenarios, Sealed Vault on its own is enough.
What goes wrong (and how to recover)
Three failure modes worth knowing:
- "vault refused to resolve secret for this URL" — your allow-list is wrong. Run
zfuzz vault list, thenzfuzz vault set <KEY> --allow-url <correct-host>. This is the system working as designed; do not loosen the allow-list to*. - OS keychain prompts on every call — macOS asks for auth the first time, then stores "always allow" for the
zfuzzbinary. If it keeps asking, you probably rebuilt the binary (the path changed). Click "Always Allow" once more. - "vault decrypt failed" — the encrypted blob and the keychain entry got out of sync, usually because you copied
vault.json.encacross machines. The fix: re-import from your last snapshot. The fail-closed behaviour is the security property, not the bug.
The threat model in one sentence
Centralised secret managers protect secrets at rest and in transit between operator and infrastructure. Sealed Vault protects secrets after they reach the agent runtime — the place where prompt-injection attacks live. You need both.
Related reading: Your AI Agent Still Leaks .env Even With Bitwarden — the threat model in detail with the @deepfates incident timeline. How to Audit Your MCP Server Configs for Prompt Injection — the broader audit playbook. AI Agent Security Risks in 2026 — the full landscape.
Start sealing your secrets
claude mcp add zfuzz -- npx -y @zfuzz/mcp
zfuzz vault init
zfuzz vault export-env ~/.hermes/.env
# verify, then:
shred -u ~/.hermes/.env
Bitwarden keeps doing what it's good at. Your agent stops leaking what it shouldn't have had in the first place.



