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:

  1. Bitwarden remains the source of truth. Rotation, audit, team sharing stay where they are.
  2. Zfuzz Sealed Vault takes the keys Bitwarden gives you and replaces them with opaque ${vault:<key>} references in the agent's environment.
  3. 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-env dance, 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:

  1. "vault refused to resolve secret for this URL" — your allow-list is wrong. Run zfuzz vault list, then zfuzz vault set <KEY> --allow-url <correct-host>. This is the system working as designed; do not loosen the allow-list to *.
  2. OS keychain prompts on every call — macOS asks for auth the first time, then stores "always allow" for the zfuzz binary. If it keeps asking, you probably rebuilt the binary (the path changed). Click "Always Allow" once more.
  3. "vault decrypt failed" — the encrypted blob and the keychain entry got out of sync, usually because you copied vault.json.enc across 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.