One Vault, Three Agents: Writing the Pattern Down Found Five Bugs In It
I launch Claude Code, Codex, and Grok from one 1Password vault, with the secrets resolved into each agent’s process environment at the moment it starts and never written to disk. I had been doing this with three near-identical shell wrappers. When I sat down to generalize them into a public repo, the act of writing the pattern down found five bugs in it - one of which had been handing every agent session the credential that unlocks the entire vault.
The repo is at https://github.com/JacobStephens2/vaulted-agent-launcher, with a short product page and install commands at https://stephens.page/vaulted-agent. This post is about what publishing it taught me, not about how to use it.
What the launcher actually does
One command, vaulted-agent claude, which becomes a dedicated service account, resolves a manifest of op:// references into its own environment, drops the vault token, and execs the agent. A second file per harness says what each agent may reach:
# harnesses.d/codex.conf
backend = onepassword
manifest = limited.env.tpl
command = codex -s danger-full-access -a on-request
The manifest is the interesting part. It names environment variables and the vault references that fill them, so it holds no secrets and a diff of it tells you exactly which credentials an agent gained or lost. Claude gets 134 references; Codex gets a subset with no production database write access. That is the thesis of the whole thing: treat the agent as the unit of authorization. An agent holding a shell is not a normal program. It improvises, it acts on text handed to it by other systems, and I do not extend identical trust to every vendor’s.
The demos below are browser replays of recorded sessions - click Run, or type a short command into the prompt. They are not a live shell (the real launcher needs local bash and a vault). The same claims are what ./demo/try-it.sh proves on your machine with no install.
The claim I had to walk back first
Before any of the bugs, I had to fix the sentence I wanted to write. “No secrets on disk” is false. One credential stays on disk: the 1Password service-account token in a mode-0640 op.env that the service account can read. Everything else is derived from it at launch.
What that buys is one credential on disk instead of thirty, central revocation, and a written answer to “which agent could reach what.” Those are worth having. But a repo about credential handling that opens with an overclaim has spent its credibility on line one, and the audience I want is exactly the audience that checks.
Bug 1: <<< writes your secrets to /tmp
The obvious 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 not
Bash serves a here-string from a pipe only while it fits in the pipe buffer, and spills to a temporary file above it. On Linux that threshold is 64 KiB, and the pipe optimization only arrived in bash 5.1 - earlier versions write the file unconditionally. You can watch it happen:
So the code is correct until the manifest grows, and then it silently writes every resolved secret to disk. My live manifest measured 13 KB across 134 keys. That is 52 KB of headroom, which means “never touches disk” was a property of my current vault size rather than of my code. The fix is to walk the string with parameter expansion, which never leaves memory.
Bug 2: the vault token rides along
set -a; . op.env; set +a exports OP_SERVICE_ACCOUNT_TOKEN, and exec claude inherits it. I checked my own running session and there it was. Every agent I had launched was holding the credential that unlocks the whole vault, not just the 134 keys I had listed for it.
One unset before the handoff closes it. Without that line, per-harness manifests are decorative: any harness could simply read the rest itself.
Bug 3: injection only adds, and I never subtracted
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 shell, and above all when one agent shells out to another, which is the entire point of running several.
I found this by giving a harness a manifest naming exactly one variable, running it, and watching my real GitHub token print in the output. The fix is to scrub to an allowlist before injecting, so the agent receives its manifest and nothing else.
Bug 4: exported functions are not variables
This is the one I want to keep. I wrote a demo that stands up a throwaway config and prints what each agent actually received, so a reader can verify the claims instead of trusting them. The first time I ran it, the table looked like this:
My scrub iterated compgen -e, which lists exported variables. Bash carries exported functions in the environment as BASH_FUNC_name%%=() { ... } and rebuilds them in the child, so the loop never saw them, and unset NAME would not have removed them regardless. The correct spelling is a second pass over declare -Fx with unset -f.
On my machine those were harmless leftovers from /etc/profile.d. The mechanism is not harmless: a caller can export a function named git, curl, or ssh, and the agent calls that instead of the binary it meant to run.
The demo I wrote to prove the launcher worked is what proved it didn’t. That is the argument for shipping a demo that prints reality rather than asserting success, and I would not have found this one by reading the code.
Same demo also checks that the secret lands in the environment and not on the command line, and that a caller-exported secret does not ride through the scrub.
Bug 5: readlink -f breaks per-path sudoers
The launcher can be invoked through per-harness symlinks - claude-conductor, codex-conductor - so that a sudoers file can grant one path per harness with no argument matching. When it re-execs itself under sudo, the reflex is to resolve $0 to the real script. Do that, and every invocation re-execs as the real launcher path, matching none of the per-path rules and quietly requiring the caller to be entitled to the launcher itself. It has to re-exec through the path that was invoked.
What it still does not do
The launcher runs as the service account, reads the backend credential as that account, and execs the agent as that same account. So the agent can open that credential file itself and query the vault directly, whatever its manifest says.
Dropping the token before exec prevents inheritance, which rules out accidents and any tool that reads OP_SERVICE_ACCOUNT_TOKEN on sight. It is not a wall against an agent that goes looking. Manifests are blast-radius control, not containment, and I had to rewrite that section of the README once I noticed.
Making them containment needs privilege separation: run the resolver as root, read a 0600 root:root token, then setpriv --reuid=agent --init-groups and exec, so the agent never had permission to read the credential in the first place. setpriv preserves the environment across the drop, which is what makes it work. I have not built it yet. The cost is that the launcher briefly runs as root, so a bug in it is worth more - which is the usual privilege-separation trade, and an argument for keeping the launcher small enough to read in one sitting.
The part I keep thinking about
Every one of these bugs had been running in production. None of them was found by using the tool, because using it works fine - the agents launch, the secrets arrive, nothing errors. They were found by having to explain the tool to strangers who would not extend me the benefit of the doubt, and by writing a demo that had to print the actual environment rather than a reassuring message.
I do not have a tidy conclusion about how much of my other infrastructure would survive the same treatment. I suspect not all of it.