Resolve secret:// references in environment variables and arguments at spawn time, then
exec the real command with the secrets injected only into the child process environment.
One committed, plaintext-free config line that works identically on macOS (Keychain) and headless Linux (Secret Service, systemd-creds, an age-encrypted file, or file-mounted credentials in containers). No daemon, no proxy, no cloud account. Single-file Python, stdlib only.
MCP servers and similar tools want long-lived API tokens in plaintext config files:
{
"mcpServers": {
"example": {
"command": "example-mcp-server",
"env": { "EXAMPLE_API_TOKEN": "live-token-sitting-in-a-dotfile" }
}
}
}That file wants to live in a dotfiles repo, but the token doesn't. With vaultexec the committed config contains only a reference; the token lives in the OS secret store and is materialized only in the environment of the spawned child:
{
"mcpServers": {
"example": {
"command": "vaultexec",
"args": ["example-mcp-server"],
"env": { "EXAMPLE_API_TOKEN": "secret://example/api-token" }
}
}
}Until the secret is stored, the server fails fast with
vaultexec: could not resolve secret://example/api-token (tried: keychain) and the child
never starts. vaultexec set example api-token completes it — no config edit.
pipx install git+https://github.com/jonfishr/vaultexec # or from a local checkout:
pipx install /path/to/vaultexec
# uv users:
uv tool install /path/to/vaultexec# 1. Store the secret in the login Keychain (prompts; value never hits argv or shell history)
vaultexec set example api-token
# 2. Verify it resolves
vaultexec get secret://example/api-token
# 3. Use it — refs anywhere in env values or args are replaced, then the command is exec'd
EXAMPLE_API_TOKEN=secret://example/api-token vaultexec example-mcp-serversecret://SERVICE/ACCOUNT keyring://SERVICE/ACCOUNT (aliases)
SERVICEandACCOUNTallowA-Z a-z 0-9 . _ -(account also allows@).- References may be a whole value or embedded in one:
"Authorization=Bearer secret://example/api-token". - Every env var value and every command argument is scanned. All occurrences of the same reference resolve with a single backend lookup.
Tried in order; the first one that returns a value wins. A reference no backend can resolve aborts the spawn with exit code 2 (the child never starts half-configured).
| Backend | Platform | Lookup |
|---|---|---|
keychain |
macOS | security find-generic-password -s SERVICE -a ACCOUNT -w |
file |
containers / systemd units | first existing file of $CREDENTIALS_DIRECTORY/SERVICE.ACCOUNT, /run/secrets/SERVICE.ACCOUNT |
secret-service |
Linux | secret-tool lookup service SERVICE account ACCOUNT |
systemd-creds |
Linux | systemd-creds decrypt /etc/credstore.encrypted/SERVICE.ACCOUNT.cred - |
age |
Linux | age -d -i ~/.config/vaultexec/identity.txt ~/.config/vaultexec/secrets.age, then look up the SERVICE/ACCOUNT= line |
Default order is keychain on darwin and file, secret-service, systemd-creds,
age on everything else. Backends whose CLI is missing or whose store file doesn't
exist are skipped silently. Override with VAULTEXEC_BACKEND (comma-separated, e.g.
VAULTEXEC_BACKEND=age). The age paths are overridable with VAULTEXEC_AGE_FILE and
VAULTEXEC_AGE_IDENTITY.
systemd-creds (best when the box has a TPM — nothing secret-zero on disk):
systemd-ask-password -n | sudo systemd-creds encrypt --name=example.api-token - /etc/credstore.encrypted/example.api-token.credage (portable fallback; the identity file is the root of trust — keep it 0600):
age-keygen -o ~/.config/vaultexec/identity.txt
printf 'example/api-token=TOKENVALUE\n' | age -e -i ~/.config/vaultexec/identity.txt > ~/.config/vaultexec/secrets.agesecret-service works on headless boxes too if gnome-keyring is unlocked via PAM at login, but it's happiest on desktops.
Docker/podman secrets and systemd LoadCredential= both deliver secrets as files,
while most apps want env vars. The file backend bridges that: vaultexec as the
entrypoint resolves secret://SERVICE/ACCOUNT from /run/secrets/SERVICE.ACCOUNT
(or $CREDENTIALS_DIRECTORY/SERVICE.ACCOUNT) — no cat-in-a-shell-script entrypoints.
# compose.yaml
services:
example:
entrypoint: ["vaultexec", "node", "server.js"]
environment:
EXAMPLE_API_TOKEN: secret://example/api-token
secrets:
- example.api-token
secrets:
example.api-token:
file: ./example.api-token # or external: true# systemd unit
[Service]
LoadCredential=example.api-token:/etc/credstore.encrypted/example.api-token.cred
Environment=EXAMPLE_API_TOKEN=secret://example/api-token
ExecStart=%h/.local/bin/vaultexec /usr/local/bin/example-daemonvaultexec [--] CMD [ARGS...] resolve refs in env + args, then execvpe(CMD)
vaultexec get REF resolve one reference and print it
vaultexec set SERVICE ACCOUNT store a secret (value read from stdin or hidden prompt)
vaultexec --version | --help
set writes to the Keychain on macOS and to secret-service on Linux; for systemd-creds
and age use their native tooling (above). Use -- before commands whose name collides
with a subcommand.
Exit codes: 2 unresolvable reference or usage error, 127 command not found, otherwise
the child's own status (vaultexec is gone after exec — signals, stdio, and exit status
all belong to the real command, which matters for stdio MCP servers).
Use at your own risk. vaultexec is a small personal tool, not an audited security
product. It removes secrets at rest from config files — it does not, and cannot,
defend against malicious code already running as your user: anything that can invoke
security or read your keyring can read these secrets without vaultexec's help. The
whole tool is one short file; read it before trusting it with credentials. No warranty
of any kind — see LICENSE.
- A reference string is as good as the secret to anyone who can place one. vaultexec
resolves
secret://…wherever it appears, so never route untrusted input (user-supplied arguments, remote-fetched config) through a vaultexec-wrapped command — an attacker who writes a reference into an argument gets the real value substituted in. - Put references in env values, not command args, when the secret would otherwise
land in the child's argv — argv is visible to every process on the box (
ps). - The child (and its children) see the plaintext in their environment. That is the point: spawn-time injection instead of at-rest plaintext. It does not defend against code you run on purpose reading its own env.
vaultexec setnever places the value in any process argv: it feedssecurity -i(macOS) orsecret-tool store(Linux) over stdin.- macOS: the first
getfrom a new process context may trigger a Keychain permission prompt; items created byvaultexec setare readable by/usr/bin/securitywithout one.
- keyring-based launchers (e.g. mcp-secret-launcher) — Python
keyringonly; no backend on headless Linux. - mcp-safe-run — same idea, unmaintained since April 2025.
- Docker MCP Toolkit secrets — requires Docker Desktop; no headless Linux.
- 1Password
op run— needs a plaintext service-account token on headless boxes (secret zero, just relocated). - Infisical Agent Vault (evaluated July 2026) — an always-running MITM proxy daemon
with a CA cert every client must trust; injects credentials into outbound HTTPS, which
breaks MCP servers that validate tokens at startup or talk to LAN devices (routers, NAS
appliances) over self-signed TLS; unlocked via
AGENT_VAULT_MASTER_PASSWORD, which on a headless box is secret zero again. Built for agent-exfiltration threat models at team scale, not for keeping a handful of tokens out of dotfiles. - HashiCorp Vault / Infisical proper — a server, an unseal story, and machine identities to babysit. Wrong weight class for personal infrastructure.
TDD, stdlib only, one file. python3 -m venv .venv && .venv/bin/pip install -e . pytest,
then .venv/bin/pytest.
MIT