Skip to content

jonfishr/vaultexec

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vaultexec

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.

The problem

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.

Install

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

Quick start (macOS)

# 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-server

Reference syntax

secret://SERVICE/ACCOUNT      keyring://SERVICE/ACCOUNT   (aliases)
  • SERVICE and ACCOUNT allow A-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.

Backends

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.

Headless Linux setup

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.cred

age (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.age

secret-service works on headless boxes too if gnome-keyring is unlocked via PAM at login, but it's happiest on desktops.

Containers and systemd units (file backend)

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-daemon

CLI

vaultexec [--] 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).

Security notes

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 set never places the value in any process argv: it feeds security -i (macOS) or secret-tool store (Linux) over stdin.
  • macOS: the first get from a new process context may trigger a Keychain permission prompt; items created by vaultexec set are readable by /usr/bin/security without one.

Why not …

  • keyring-based launchers (e.g. mcp-secret-launcher) — Python keyring only; 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.

Development

TDD, stdlib only, one file. python3 -m venv .venv && .venv/bin/pip install -e . pytest, then .venv/bin/pytest.

License

MIT

About

Resolve secret:// references in env vars at spawn time, then exec — macOS Keychain, secret-service, systemd-creds, age, and container secrets. No daemon, single file, stdlib only.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages