Give Claude Code, Codex, Grok, and Kimi Code real vault credentials
in-process - without leaving a pile of .env files on disk.
(Kimi Code 0.33+ currently needs env = KIMI_CODE_LEGACY_FLAG = 1 on the
harness until kimi-code#2746
ships; see AGENTS.md and issue #70.)
One launcher (va), one secrets backend (1Password, Bitwarden Secrets Manager,
pass, sops, …), per-agent manifests for blast radius. Optional prompt
auth: paste the vault token at each launch so even the manager token need not
live on disk. Same scrub/resolve/exec path for one-shot tools via va run.
macOS and Linux. Product page: vaultedagent.com · Latest: v0.4.17 (Rust runtime; Bash retired — see MIGRATION.md)
- Quick start · Everyday commands
- How it works · Honest claim
- Install details · Configuration · Backends
- Uninstall · MIGRATION.md · AGENTS.md (agent-oriented contract)
Three steps. Rust binary on macOS and Linux.
curl -fsSL https://vaultedagent.com/install.sh | bashInstalls vaulted-agent and va, detects agents on PATH (claude, codex,
grok, kimi), and can ask for a vault backend + auth mode. Pin:
VAULTED_AGENT_VERSION=v0.4.17 (or latest).
va setup # auth mode, who agents run as, start directory, vault backend
va doctor # health check as the account a launch would useva setup (interactive) also asks who agents run as (you vs a
service_user) and where they start (workdir = caller vs a fixed path),
and warns when those two combine on a locked-down home. Non-interactive setup
leaves existing choices alone.
Day-one harnesses start with no vault secrets until you set them up.
Bitwarden: va setup bitwarden builds a refs file (env var → secret reference
only). Point harnesses at it, or use va run -m ….
va claude
va codex
va grok
va kimi # --auto; vault inject OPENAI_API_KEY (by provider type); see AGENTS.mdWith auth_mode=prompt, paste the vault manager token when asked (not written
to disk). Force once on the va path: va grok -p. Under a *-conductor
symlink, -p is the agent's flag; use VAULTED_AGENT_PROMPT_AUTH=1 for prompt
auth there.
va # list harnesses
va pick # interactive menu
va -m readonly.env.tpl claude # this launch only, against another manifest
va claude --resume <id> # agent args pass through; resume shape is normalized
va doctor
va secrets list # Bitwarden SM (same auth as launches)
va secrets validate # resolves every ref against the vault
va secrets validate --offline # syntax only, no token needed
va refresh # build/update a refs file (Bitwarden or 1Password)
va edit-manifest # open a refs file in $EDITOR; check on save
va auth-mode prompt # or: file
sudo va uninstallBuilding a refs file from 1Password. va refresh lists the items the token
can see and asks which to include; each chosen item's fields become
VAR=op://VAULT/ITEM/FIELD lines. Only the items you pick are read, so the
prompt appears immediately rather than after a call per item. Refresh output is
meant to inject without hand-editing: notes fields are skipped, item titles
op cannot parse fall back to the item id, and the generated header never
contains a sample op:// that would abort inject. To hand-edit an existing
refs file with the same checks doctor uses, run va edit-manifest.
va refresh # backend comes from your harnesses
va refresh --backend onepassword # or say it explicitly
va refresh --exclude '*_USERNAME' # and never map theseItems visible to this token:
1) anthropic (Orchestrator)
2) mysql8.etadventures.com (Orchestrator)
3) github token (Orchestrator)
Items to include (e.g. 1,4,7 - blank for all):
Fields in a section are referenced as op://VAULT/ITEM/SECTION/FIELD. That
matters: one item can hold several fields with the same label in different
sections - a host item with a top-level password plus one per section - and
they are different secrets. Pick the items an agent actually needs; a manifest
naming the whole vault hands every secret to every agent it launches, which is
the opposite of what manifests are for.
A section the operator named goes into the variable name, because the label
alone is not unique within an item. The one 1Password supplies itself does not:
fields added without choosing a section land in a section labelled add more,
and ANTHROPIC_CONDUCTOR_API_KEY is the name worth having.
Leaving fields out. An item holds more than its credential - the username
beside the password, or a password field reading google because the account
signs in with Google. --exclude takes a variable-name pattern (* and ?,
whole name, case-insensitive), repeats, and is recorded in the manifest as an
# exclude: line so later runs honour it without retyping. Excluded fields are
listed on every run rather than dropped quietly. Delete the line to map them
again.
Launching against another manifest. va -m <manifest> <harness> runs a
harness against a manifest other than its configured one — useful for taking a
narrower set of credentials into a session without editing config. It replaces
the manifest rather than merging, errors if the file is missing, and prints
which manifest it used. Also works with va -m <manifest> pick.
It is a launcher flag, so it goes before the harness name. It is refused under
a *-conductor symlink, alongside -H: that symlink is what lets a sudoers
rule grant one harness and have it mean one set of credentials, and a flag
naming the manifest would undo it. On the direct va path a caller can already
reach va run -m with any manifest, so there is nothing extra to protect.
With service_user and a sudoers rule that only names vaulted-agent claude,
a line that starts with -m will not match. Use conductor links for delegated
grants, or add rules that allow the launcher flags before the harness name.
va doctor runs as service_user when configured (same hop as a launch)
and reports token files as present, missing, or unreadable - never
collapsing permission denied into missing. For 1Password harnesses it also
flags op:// references the scanner cannot parse (plain literals are fine).
One-shot (any command, no harness file):
va run -m openai.env.refs --backend bitwarden -p -- \
python gpt_image.py generate "a lighthouse" --output out.pngWhen secrets change (Bitwarden SM):
| In Secrets Manager | What to run |
|---|---|
| Rotated a value | Nothing — next launch fetches live |
| Added a secret you want mapped | va refresh (merge) or va refresh --replace --all |
| Removed or fixed a mapping | va edit-manifest (checks on save) or edit the refs file by hand |
Install options (clone, shared host, flags): Install details.
Auth / backends / harness format: Configuration · Backends.
you $ va claude --resume <session-id>
│
│ first argument selects the harness; rest → agent
▼
/usr/local/bin/vaulted-agent (alias: va)
│
├─ optional sudo -u <service> become the service account
├─ cd workdir caller cwd by default (sessions / resume)
├─ scrub environment allowlist only; nothing inherited rides along
├─ load vault auth op.env / bws.env, or prompt (auth_mode)
├─ resolve manifest refs op inject / bws secret get / …
├─ unset vault token the agent must not inherit the master key
└─ exec claude … --resume <id>
│
└─ secrets live here, in this process, until it exits
Thesis: treat the agent as the unit of authorization. Manifests are blast-radius control, not containment.
Writeup: One vault, three agents · Latest: v0.4.17
This is not automatically "no secrets on disk." With auth_mode=file, one
credential stays on disk: the vault service-account token in op.env or
bws.env (mode 0640, readable by the service account). With
auth_mode=prompt, the launcher never writes that token either - you paste it
each launch. Resolved secrets (API keys, DB passwords) are never written by
the launcher in either mode; they live only in the child process environment.
What you get for that trade:
- One credential on disk instead of thirty (or zero manager tokens on disk
with prompt auth). A backup, a stray
tar, a misconfigured sync, or a readable dotfile exposes one token, not the fleet. - Central revocation. Rotate in the vault and every future launch picks it
up. There is no scavenger hunt through
.envfiles. - A written answer to "which agent could reach what." Manifests are the answer, and they are diffable and reviewable.
- Nothing on the command line, so
psshows nothing to any user on the box. va runfor tools and scripts without inventing a harness per binary.- Fail-closed manifests - placeholders and bad UUIDs never reach the vault.
What it does not protect against, stated plainly because a repo about credential handling should not leave you to discover these:
- The agent can read its own environment, and so can anything running as
the same user, via
/proc/<pid>/environ. This is unavoidable: the agent needs the credentials to do the work. The mitigations are a dedicated service account with no other processes, and a narrow manifest. - The agent can exfiltrate what it holds. It has a shell. A prompt injection that reaches a tool call can use every credential in its manifest. A narrow manifest limits how much that costs you; nothing here prevents it.
- A harness can read past its own manifest when the vault token is on disk
(
auth_mode=file). The launcher runs as the same account it hands off to; that account can readbws.env/op.envand query the vault directly. Dropping the token beforeexecstops it being inherited, which rules out accidents and casual reuse by tools that readOP_SERVICE_ACCOUNT_TOKENorBWS_ACCESS_TOKENon sight - but it is not a wall against an agent that goes looking. Treat manifests as blast radius control, not as containment. Making them containment needs privilege separation; see below. Prompt auth removes the on-disk token file but does not stop an agent that already holds resolved secrets. - No TTY for a token prompt when one agent shells out to another. Use
auth_mode=file, or export the vault token in the parent beforeva …. The launcher’s error text says this explicitly. - The vault token is a master key for whatever it can read. Scope the service account to a single vault, and only the items an agent needs.
- Root can read everything. Nothing here defends against a compromised host.
If you already have the tree (clone, release tarball, or the remote bootstrap's temp dir), the real installer is:
sudo ./install.shBy default agents run as you - the user who invoked install.sh - and the
command is symlinked into your ~/.local/bin so it is on your PATH. That is
the right default on a personal machine and needs no setup.
On a shared host, use a dedicated account instead:
sudo useradd --system --home-dir /srv/agent --create-home --shell /bin/bash agent
sudo ./install.sh --user agent --allow-user aliceThe reason is the threat model above: everything running as the agent's user
can read the agent's environment through /proc/<pid>/environ. When that user
is you, that is your shell, your editor, and anything else you happen to be
running. A dedicated account with nothing else in it makes "the same user" as
small a set as possible, and makes the audit trail say "the agent did this"
rather than naming a person. Running as root is refused outright.
install.sh installs the Rust binary and writes machine defaults to
defaults.conf (never sed-patches a shell script). It never overwrites a
config file you have edited. Useful flags:
| flag | |
|---|---|
--user NAME |
the service account to run agents as; defaults to you (writes service_user in defaults.conf when explicit) |
--no-link |
skip the default ~/.local/bin symlink |
--no-va |
skip the short va alias (default is to install it) |
--no-auto-harness |
do not detect claude/codex/grok/kimi or write live harnesses |
--no-setup |
skip interactive vault backend questions |
--backend NAME |
onepassword, bitwarden, pass, sops, or skip. Sets default_backend in defaults.conf and the summary’s token path (bws.env vs op.env) |
--auth-mode MODE |
file (token on disk) or prompt (paste each launch; default file) |
--op-token-file PATH |
write OP_SERVICE_ACCOUNT_TOKEN from this file (not argv); ignored when --auth-mode prompt |
--bws-token-file PATH |
write BWS_ACCESS_TOKEN from this file; ignored when --auth-mode prompt |
--workdir DIR |
working directory; defaults to that account's home |
| --op-env FILE | reuse a backend credential that already exists elsewhere |
| --links a,b,c | also create a-conductor, b-conductor, … symlinks |
| --allow-user NAME | write a sudoers rule letting NAME launch any harness |
| --link-user NAME | symlink into NAME's ~/.local/bin, so the command is on their PATH |
| --force | replace a pre-existing path that is not our symlink |
| --dry-run | print what it would do |
If a symlink path is already taken by something that is not ours, it stops
rather than replacing it. A box that already has launchers of its own using
those names keeps them unless you pass --force.
If vaulted-agent comes back "command not found" afterwards, /usr/local/bin
is not on your PATH - which is common enough to be worth expecting. Either
re-run with --link-user <you>, or link it yourself:
mkdir -p ~/.local/bin && ln -s /usr/local/bin/vaulted-agent ~/.local/bin/vaulted-agentThe link can live in any directory on your PATH. The sudo re-exec always
rebuilds the path as /usr/local/bin/vaulted-agent, so your sudoers rule
matches either way.
install.sh warns when it can prove the command is unreachable, but it cannot
prove the opposite: $PATH under sudo is root's secure_path, and su -l
synthesizes one from /etc/login.defs. Both routinely contain /usr/local/bin
when your interactive shell does not. So it also prints command -v vaulted-agent for you to run in your own shell, where the answer is real.
That is enough to run it as the service account:
$ sudo -u agent vaulted-agent # lists the configured harnesses
$ sudo -u agent vaulted-agent claudeOr choose one interactively:
$ vaulted-agent pick
1) claude claude --permission-mode auto full.env.tpl
2) claude-ro claude --permission-mode auto readonly.env.tpl
3) codex codex -s danger-full-access -a on-r... limited.env.tpl
4) grok grok readonly.env.tpl
harness [1-4, q to quit]: 2Picking resolves to a concrete harness and then re-execs as though you had
typed vaulted-agent claude-ro, so per-harness sudoers rules still apply and
you are never authorized for more by choosing from a menu. pick is reserved
unless a harness of that name genuinely exists.
Two ways, and the choice is really about how precisely you need to authorize.
If everyone who can launch an agent may launch every harness, one rule is enough:
# /etc/sudoers.d/vaulted-agent
alice ALL=(agent) NOPASSWD: /usr/local/bin/vaulted-agent
Then vaulted-agent claude and vaulted-agent codex both work, and adding a
harness needs no sudoers change. Note what this grants: any harness,
including ones added later.
If different people get different harnesses, install a symlink per harness and name each path:
sudo ln -s /usr/local/bin/vaulted-agent /usr/local/bin/claude-conductor
sudo ln -s /usr/local/bin/vaulted-agent /usr/local/bin/codex-conductor
sudo ln -s /usr/local/bin/vaulted-agent /usr/local/bin/grok-conductoralice ALL=(agent) NOPASSWD: /usr/local/bin/claude-conductor
alice ALL=(agent) NOPASSWD: /usr/local/bin/codex-conductor
bob ALL=(agent) NOPASSWD: /usr/local/bin/grok-conductor
Invoked through a link the link name is authoritative and -H is refused, so
Bob cannot reach Alice's harnesses. This is the form to prefer, because it
needs no argument matching: each rule is a plain path.
You can authorize the positional form per harness instead, and the launcher
refuses vaulted-agent grok -H claude specifically so that this holds:
bob ALL=(agent) NOPASSWD: /usr/local/bin/vaulted-agent grok
bob ALL=(agent) NOPASSWD: /usr/local/bin/vaulted-agent grok *
Both lines are needed, since the first matches only the bare invocation. Two rules with a wildcard is more to get right than one path, which is why the symlinks stay the recommendation for this case.
Every rule above names this launcher because that is the command the re-exec
hands to sudo. It matters that nothing is prefixed to it: an earlier version put
env KEY=val … in front, so the command sudo matched was /usr/bin/env and a
rule naming the launcher never matched at all. It looked like it worked, because
the people testing it already held blanket sudo. If you are carrying such a rule
forward from before v0.4.3, check it actually matches — sudo -l -U alice.
run takes its command from the caller rather than from a command = line that
root wrote, so it is the one subcommand that turns this launcher into a general
executor. Granting someone vaulted-agent for one harness would otherwise also
grant them run -- /bin/sh as the service account.
So run is disabled whenever service_user is set, which is the signal that
the launcher is delegated. On a single-operator machine with no service account
it is unchanged, since there it grants nothing the caller did not already have.
To restore it deliberately:
# /etc/vaulted-agent/defaults.conf
allow_run = yesFor the same reason VAULTED_AGENT_CONFIG_DIR is not carried across the hop.
A caller-chosen config directory would let anyone entitled to one harness supply
their own harness file — and a harness file names its own command =. The
elevated side always reads the machine config directory. Point
VAULTED_AGENT_CONFIG_DIR at a test tree and it still works exactly as before
when no elevation happens.
Finally, resist giving the service account broad sudo. It is the account your
agent runs as, and agent ALL=(ALL) NOPASSWD: ALL makes every manifest
boundary above it decorative — and turns each of the paragraphs above from a
hardening measure into the only thing standing between a harness grant and root.
Maintainers: the curl … | bash one-liner serves install-remote.sh from this
repo, and refreshing it has an ordering constraint worth knowing before you cut
a release - see docs/hosting-the-installer.md.
Remove it the same way you run agents - uninstall lives in the installed
binary, so you do not need the git tree (works after a curl | bash install):
sudo vaulted-agent uninstall # interactive; keeps config
sudo vaulted-agent uninstall --purge # also remove config
sudo vaulted-agent uninstall --dry-run # show the plan only
sudo vaulted-agent uninstall --yes # no prompts (scripts/cron)
# short alias:
sudo va uninstall --purgeIt prompts when a terminal is present, lists the exact paths it would remove, and asks once more before deleting. A symlink is only removed when it resolves to this launcher; anything else at those paths is left alone (“not ours”).
Config is kept without --purge, since harness files are usually hand-written.
Backend credentials are never removed - op.env, bws.env and age.key
may be shared with other tooling. Delete those yourself if you want them gone.
Add --link-user NAME to also remove that user's ~/.local/bin symlink; the
user who invoked sudo is checked automatically.
From a checkout (no installed binary yet), the same logic is also reachable as
sudo ./install.sh --uninstall ….
One file per harness in harnesses.d/, named for the harness. claude.conf
is what claude-conductor launches:
# /etc/vaulted-agent/harnesses.d/claude.conf
bin = $HOME/.local/bin
manifest = full.env.tpl
labels = yes
command = claude --permission-mode auto| key | meaning |
|---|---|
backend |
onepassword, bitwarden, sops, pass, or plainfile |
manifest |
the secrets to load. This is the blast radius. |
bin |
prepended to PATH before exec; $HOME expands |
workdir |
agent cwd: unset → install default; caller → your shell’s directory (needed for --resume / project sessions); or an absolute/$HOME path |
labels |
map non-UUID --resume/--session-id values to a stable UUIDv5 |
keep |
extra variables surviving the environment scrub, comma separated |
alias |
repeatable: TARGET = SOURCE — copy an injected secret onto another name in this harness's child env only (fail closed if SOURCE missing; see issue #66) |
env |
repeatable: NAME = value — non-secret child env (not vault material; e.g. temporary KIMI_CODE_LEGACY_FLAG on kimi.conf, issue #70) |
command |
the command line, split on whitespace |
arg |
one further argument, verbatim. Repeatable, and the only way to pass one containing a space |
See Resume sessions above for va claude|codex|grok|kimi
resume examples. Native CLIs still differ without va: Claude/Grok use
--resume; Codex uses the resume subcommand; Kimi Code uses --continue /
--session (and accepts --resume as an alias).
Whitespace around the key, the =, and the value is ignored, so align them
however you like. Your own arguments are appended after the configured ones.
Manifests say what each harness may reach. 1Password (op:// refs):
APP_DB_HOST=op://AgentVault/app-database/hostname
APP_DB_USER=op://AgentVault/app-database/mysql/username
APP_DB_PASS=op://AgentVault/app-database/mysql/password
GH_TOKEN=op://AgentVault/github/fine-grained-token
Bitwarden (UUID / name: / project:; see Bitwarden Secrets Manager):
OPENAI_API_KEY=name:openai-api-key
GH_TOKEN=project:tools/github-token
A refs manifest holds references, never values, so it is safe to commit and
safe to leave world-readable. Adding a secret is one line plus a vault entry;
the next launch has it. Syntax is checked before resolve
(va secrets validate, va doctor, every launch).
The same agent can appear more than once: claude.conf and claude-ro.conf
run the identical command against different manifests. Separate files and
separate symlinks are what let the sudoers file distinguish who may launch it
with credentials that can change production and who gets the read-only set.
Why this format. Config is parsed, never sourced. That is the whole of the safety argument, and it would hold just as well for JSON or a whitespace-aligned table. Sourcing is what would be unsafe - it turns the config file into arbitrary shell executed as the account holding the vault token - and it is what most shell projects do.
Given that, the choice among safe formats is about editing failure modes, and
drop-in key = value files win on three:
- A structured format (JSON, YAML, TOML) needs a parser. In bash that means
shelling out to
python3orjqin the launch path, then getting values back into the shell withouteval. It is doable, but adding an interpreter to the critical path of a credential launcher is a poor trade for syntax. JSON also has no comments, and the config deserves them. - An aligned table makes column position load-bearing. Someone tidying the alignment can shift a field, and a value can never contain a space.
- Drop-in files make adding a harness a new file rather than an edit to a
shared one, which is how
sudoers.dandsystemdunits already work, and they map one-to-one onto the symlink and the sudoers line.
So: yes, the spacing in the examples is purely cosmetic, and no, the free-form-ness was never what made it safe.
Set per harness with backend =, or set machine default default_backend in
/etc/vaulted-agent/defaults.conf (install --backend, or edit the file).
| backend | manifest is | on-disk credential | resolves |
|---|---|---|---|
onepassword |
VAR=op://vault/item/field |
op.env (OP_SERVICE_ACCOUNT_TOKEN) when auth_mode=file |
whole file, one op inject |
bitwarden |
VAR=<uuid|uuid:…|name:KEY|project:P/KEY> |
bws.env (BWS_ACCESS_TOKEN) when auth_mode=file |
one bws secret get per line (names resolved via bws secret list) |
pass |
VAR=store/entry/path |
the service account's GPG key | one pass show per line |
sops |
a sops-encrypted dotenv | age.key |
whole file, one sops --decrypt |
plainfile |
a plain dotenv | the manifest itself | nothing to resolve |
Machine-wide default lives in /etc/vaulted-agent/defaults.conf
(auth_mode = file|prompt). This controls where the vault manager token
comes from, not where resolved secrets are stored (those are never written).
| mode | Token provision | On disk |
|---|---|---|
file |
Read from dotenv at launch | 1Password: /etc/vaulted-agent/op.env (OP_SERVICE_ACCOUNT_TOKEN=…). Bitwarden: /etc/vaulted-agent/bws.env (BWS_ACCESS_TOKEN=…). Mode 0640, root + service-user group. |
prompt |
Paste on a TTY each launch (or inherit if already exported) | Nothing written by the launcher. |
Set at install (interactive question, or --auth-mode), or later:
vaulted-agent auth-mode # interactive (TTY) or print current
vaulted-agent auth-mode prompt # nothing on disk
vaulted-agent auth-mode file # use op.env / bws.env
va claude -p # force prompt for this launch onlyHow the token is chosen on a single launch (first match wins for “prompt this launch”):
-p/--prompt-authVAULTED_AGENT_PROMPT_AUTH=1VAULTED_AGENT_AUTH_MODE=prompt|filedefaults.confauth_mode- built-in default
file
Within a launch, if the token is already in the environment, that wins over
file and prompt. If auth_mode=file but the file is missing and a TTY
exists, the launcher offers a one-shot “this launch only” paste. If the file is
present but unreadable (typical when it is root:service_user mode 0640
and this process is not that user), the launcher fails closed with a message
that names the effective user and points at service_user - it does not pretend
the file is missing or invite you to paste a vault service-account token.
va setup and the token file. On a TTY, setup first asks how vault manager
tokens should be supplied (same menu as install / auth-mode):
How should vault tokens be supplied at launch?
1) file — store once in op.env / bws.env (no prompt each run)
2) prompt — paste token each launch; nothing stored on disk
That choice is written to defaults.conf before backend work. Non-interactive
setup leaves the existing auth_mode alone.
Then, for the vault backend: 1Password setup writes op.env and Bitwarden
writes bws.env only when auth_mode=file. Under auth_mode=prompt,
setup uses the token only in-process for bws secret list / refs building,
then exits — nothing persisted. It prints:
auth_mode=prompt — token not written to disk (good).
To store it anyway: vaulted-agent auth-mode file, then re-run setup.
The split that matters is not which vendor, it is reference versus payload.
With onepassword, bitwarden and pass, the manifest names secrets it does
not contain. It is safe to commit, safe to leave world-readable, and reviewing
a change to it tells you exactly which credentials an agent gained or lost.
Rotation happens in the vault and the next launch picks it up.
With sops and plainfile, the manifest is the secrets. Per-harness
scoping then means maintaining a separate encrypted file per harness, and
rotation means re-encrypting and redeploying every one of them. sops at
least keeps them encrypted at rest and diffable in git; plainfile is a
0600 dotenv with none of the benefits this repo argues for, included so the
pattern can be demonstrated without signing up for anything. Do not reach for
it in production.
Per-key backends cost a round trip per variable, which is slower on a large manifest. They buy something in return: a value containing a newline cannot run over into the next variable, because each one is fetched and exported on its own rather than parsed out of a shared document.
Adding a sixth backend is one case arm in resolve, which is also how you
would swap in vault, chamber, aws-vault, or gopass.
The bitwarden backend is Secrets Manager (bws), not the personal vault
CLI (bw).
| Credential | BWS_ACCESS_TOKEN - a Machine Account access token (Machine Accounts → Access Tokens) |
| Not valid | personal vault master password, login API key, session tokens |
On disk (auth_mode=file) |
/etc/vaulted-agent/bws.env → BWS_ACCESS_TOKEN=… (0640) |
On disk (auth_mode=prompt) |
none - paste each launch |
Wrong token types fail with errors such as “Doesn't contain a decryption key.”
Reference forms in a refs manifest (no secret values in the file):
| Form | Example |
|---|---|
| UUID | OPENAI_API_KEY=6a1c0e94-… |
uuid: |
OPENAI_API_KEY=uuid:6a1c0e94-… |
name: |
OPENAI_API_KEY=name:openai-api-key |
project:/ |
OPENAI_API_KEY=project:tools/openai-api-key |
Names resolve via bws secret list once per process. Prefer
vaulted-agent secrets list / vaulted-agent setup over raw bws so auth
matches launches.
Refs file (what setup / refresh write). After listing secrets, setup (or
va refresh) can write a refs file under /etc/vaulted-agent/manifests/
(default name openai.env.refs). That is only a filename for lines like
OPENAI_API_KEY=name:… - not a secret, not the access token. Point a harness
at it with backend = bitwarden and manifest = openai.env.refs, or:
va run -m openai.env.refs --backend bitwarden -p -- your-commandWhen you add secrets in Secrets Manager later (same process as setup’s refs builder, on an existing file):
va refresh openai.env.refs # merge: show what’s new, append picks
va refresh openai.env.refs --all # append every not-yet-mapped secret
va refresh openai.env.refs --replace --all # rewrite file from scratchRotating a secret’s value in SM needs no refresh - the next launch fetches
it live. Placeholder values (REPLACE_…, all-zero UUIDs, …) are rejected by
secrets validate, doctor, and every launch before talking to the vault.
As shipped, the launcher runs as the service account, reads the backend
credential as that account, and execs the agent as that same account. The
agent can therefore read the credential file itself. Manifests bound what each
harness is handed, not what it can obtain.
If you need the stronger property, separate the two roles:
you --sudo--> root reads the token (0600 root:root)
resolves the manifest into its own environment
drops the token, scrubs the environment
setpriv --reuid=agent --regid=agent --init-groups
--> exec the agent, which now cannot read the token
setpriv from util-linux preserves the environment across the privilege drop,
which is what makes this work: the resolved secrets survive, the credential
that produced them does not, and agent never had permission to read it in
the first place.
The cost is that the launcher briefly runs as root, so a bug in it is worth more. That is the usual privilege-separation trade, and it is why the launcher is small enough to read in one sitting.
This is not wired up here yet. If you adopt the pattern and need containment rather than blast-radius control, this is the shape to build.
7. Exported shell functions survive a variable scrub. Removing every
exported variable not on an allowlist looks like it produces a clean
environment. It does not. Bash carries exported functions in the environment
as BASH_FUNC_name%%=() { ... } and rebuilds them in the child, and they are
invisible to compgen -e, so a loop over exported variables never sees
them and unset NAME would not remove them anyway:
$ vaulted-agent claude # before the fix
BASH_FUNC_which%% BASH_FUNC_module%% BASH_FUNC_scl%% BASH_FUNC_ml%%Harmless-looking, and on most systems those come from /etc/profile.d. But
the mechanism is the point: a caller can export a function named git,
curl, or ssh, and the agent calls it instead of the binary it meant to
run. The fix is a second pass with declare -Fx and unset -f.
This one is easy to miss if you only inspect compgen -e or a success banner:
inspect what the agent process actually received (/proc/<pid>/environ, or a
one-shot harness that prints env) after a scrub.
Runtime is a single Rust binary (vaulted-agent / va). Normal install
uses a GitHub release asset (or VAULTED_AGENT_BIN); source install needs
cargo once to build. Host tools by backend:
The Linux assets are statically linked against musl, so there is no minimum glibc and no shared-library requirement — the same binary runs on RHEL/Rocky 9, Debian, Ubuntu, and Alpine. See ADR 0001 for why, and before changing it.
Installed from a release up to v0.4.0 on a distro older than Ubuntu 24.04 and got
va: /lib64/libc.so.6: version 'GLIBC_2.39' not found (required by va)? Those assets were glibc-linked and built on a newer runner, sold.sorefuses to load them. Re-run the installer at v0.4.1 or later, or build on the host (cargo build --release --lockedthensudo ./install.sh). Nothing needs to change on the machine — installing glibc 2.39 under RHEL 9 is not a supported operation, and the binary does not actually use anything from it.
| feature | needs on PATH |
|---|---|
backend = bitwarden |
bws (JSON parsed in-process; no python3) |
backend = onepassword |
op |
backend = pass |
pass |
backend = sops |
sops |
labels = yes |
nothing extra (UUIDv5 in-process) |
pick is a numbered menu on /dev/tty, not fzf. See MIGRATION.md
for Bash → Rust notes.
These are the bugs this launcher exists to not have. Each one was found in a working implementation of this pattern.
1. <<< writes your secrets to /tmp. The natural way to walk the
resolved output is a here-string:
injected=$(op inject -i "$manifest")
while IFS= read -r line; do export "$line"; done <<< "$injected" # DO NOTBash serves a here-string from a pipe only while it fits in the pipe buffer,
and spills to a /tmp/sh-thd.XXXXXX file above it. On Linux that threshold is
64 KiB, and the pipe optimisation only arrived in bash 5.1 - earlier versions
write the file unconditionally. So this code is correct until your manifest
grows, and then it silently writes every secret to disk. Check for yourself:
$ bash -c 'readlink /proc/self/fd/0' <<< "small"
pipe:[112050729]
$ big=$(head -c 100000 /dev/zero | tr '\0' a)
$ bash -c 'readlink /proc/self/fd/0' <<< "$big"
/tmp/sh-thd.PJd9QD (deleted)vaulted-agent walks the string with parameter expansion instead. It never
leaves memory, it keeps op inject's exit status, and it runs in the current
shell so the exports survive.
2. The vault token rides along into the agent. Sourcing the token file
with set -a exports it, and execing the agent hands it over:
set -a; . op.env; set +a # exports OP_SERVICE_ACCOUNT_TOKEN
...
exec claude # which now inherits itThe agent is now holding the credential that unlocks the whole vault, so it
can read every item, not just the ones in its manifest. Per-harness manifests
are decorative until you unset OP_SERVICE_ACCOUNT_TOKEN before the handoff.
3. Injection only adds; you must also subtract. A narrow manifest
constrains nothing if the process inherits a wide environment. sudo resets
the environment on the cross-user hop, which makes this look handled - but the
launcher also runs with no sudo hop at all: from cron as the service account,
from a service-account login shell, and above all when one agent shells out
to another, which is the whole point of running several. In that path the
child inherits the parent's full set and the manifest describes a boundary
that does not exist. vaulted-agent scrubs to an allowlist before injecting,
so the agent receives exactly its manifest plus PASSTHROUGH_VARS.
4. readlink -f "$0" breaks per-path sudoers. With symlink dispatch, the
reflex when re-execing under sudo is to resolve $0 to the real script. Do
that and every invocation re-execs as /usr/local/bin/vaulted-agent, matching
none of the per-harness sudoers rules, and quietly requiring the caller to be
entitled to the launcher itself. Re-exec through the path that was invoked.
5. eval mangles perfectly legal secrets. Passwords contain $,
backticks, quotes and spaces. eval "$line" re-expands them, which corrupts
some values and executes others. export "$line" assigns the whole string as
name=value with no further expansion.
6. Your comments come back through op inject. The manifest is a template,
so comment lines survive substitution and land in the loop with everything
else. A documentation line like
# KEY=op://<vault>/<item>/<field>
contains an =, and a skip test that only rejects blank lines will hand it to
export, which fails with not a valid identifier and takes the launch down
with it. Beware also that in bash, [[ "$line" == [[:space:]]*"#"* ]] does
not match a line that starts with # at column one. Trim the line first,
then test its first character.
The same pattern works for a daemon that needs vault secrets, with one difference: there is no process to inject into until systemd starts it, so the resolved values have to land somewhere the unit can read.
Render them into a tmpfs under RuntimeDirectory=, never onto persistent
disk, and let the token stay in the ExecStartPre script rather than the
service environment:
[Service]
RuntimeDirectory=myservice
RuntimeDirectoryMode=0750
ExecStartPre=/usr/local/bin/render-env
EnvironmentFile=/run/myservice/env/run is tmpfs, so the file is gone on reboot and never hits the block
device. It is a genuine step down in protection from the interactive case -
the values exist as a file, readable by that unit's user, for the lifetime of
the service. Prefer the launcher where you can.
Runtime secret injection is not new: op run, sops exec-env,
vault agent, chamber exec, and aws-vault exec all do a version of it,
and any of them drops into the case statement alongside the five here.
What is specific to this repo is treating the agent as the unit of authorization. An AI agent holding a shell is not a normal program: it improvises, it acts on text handed to it by other systems, and you may not extend the same trust to every vendor's. This is a way to give several of them credentials from one vault while writing down, per agent, exactly which credentials those are.
Generalized from a production setup where agents from three vendors share one vault, each carrying its own manifest.
Paths, account names, and vault layout in this repo deliberately differ from that deployment. Adapt the examples rather than copying them as a working configuration.
MIT.