Your AI Agent Is Leaking Your Secrets
I audited a secret manager recently. It had everything: AES-256-GCM encryption at rest, X25519 sealed responses for agent workflows, deny-by-default ACLs with path-based permissions, token hashing, attestation, mTLS support. The crypto was solid. The architecture was sound. And it leaked secrets in the stupidest way possible.
The exec command — the one agents use to run commands that need secrets — used syscall.Exec with no output interception. A child process printing echo key=$API_KEY sent the raw secret value straight to the model. No scrubbing. No filtering. No warning.
The agent saw the secret. The model saw the secret. The logs saw the secret. The transcript saw the secret. Everyone saw the secret except the human who thought they were protecting it.
The three flaws
After the audit I found three critical issues, each worse than the last:
1. exec leaked secrets via child output
The exec command's job is simple: resolve a secret, inject it as an environment variable, run a child process. The child needs the secret to do its job — connect to a database, call an API, sign a request. But the child's output also flows back to the agent that launched it.
Phoenix used syscall.Exec, which replaces the current process with the child. No capture, no interception, no scrubbing. If the child prints the secret — in a debug log, in an error message, in a stack trace — it goes directly to stdout, which goes directly to the model.
This is not a theoretical concern. Children print secrets all the time:
DEBUG: connecting with key=sk-xxx...Error: authentication failed for token eyJ...Config loaded: {"api_key": "sk-xxx", "port": 8080}
Every one of those is a secret leak. The agent sees it. The model sees it. The transcript preserves it forever.
2. resolve and get printed raw secrets
The resolve command — designed for agent workflows — returned the raw secret value in its JSON response. The get command printed the raw secret to stdout by default.
So even if you never used exec, just asking for a secret leaked it. The agent calls resolve veil://myapp/api-key and gets back {"value": "sk-live-xxx"}. That value is now in the model's context window. It's in the transcript. It's in any tool output the model sees.
3. MCP plaintext was the default
Phoenix had a sealed response mode — X25519 encrypted tokens that agents can't decrypt. But it was opt-in. The default was plaintext. So unless you explicitly configured sealed mode, every MCP response containing a secret was sent in the clear.
Defaults matter. If security is opt-in, most users won't opt in. They'll run with the defaults and not realize their secrets are exposed.
Why this matters now
Agents are running commands on real infrastructure. They're deploying apps, managing databases, calling APIs. They need secrets to do their jobs. And they're getting those secrets from tools that were designed for humans, not for models.
Human-facing secret managers assume the human can see the secret. That's fine — the human is authorized. But when an agent retrieves a secret, the secret goes into the model's context window. The model sees it. The transcript preserves it. If the model is a cloud API, the secret leaves your machine.
This is a new threat model. Traditional secret managers weren't designed for it. They protect secrets at rest and in transit, but not in the model's context.
veil: the fix
I built veil — a clean-room reimplementation of the Phoenix concept in machin/MFL. It fixes all three flaws.
Fix 1: Mandatory output scrubbing
veil exec doesn't use syscall.Exec. It captures the child's stdout and stderr, replaces every known secret with ***, and only then emits the scrubbed output.
veil exec --env API_KEY=veil://myapp/api-key -- python app.py
The child process gets the real secret in its environment. It can connect to the database, call the API, sign the request. But if it prints the secret — in a log, in an error, in a traceback — the agent sees ***, not the raw value.
$ veil exec --env API_KEY=veil://myapp/api-key -- sh -c 'echo "key=$API_KEY"' key=***
The scrubbing handles exact matches, base64-encoded variants, and URL-encoded variants. The child exit code is preserved. The agent sees the command output but not the secrets.
Fix 2: No raw output by default
veil get refuses to print secrets to stdout. You must either use -o <file> to write to a file (mode 0600), or explicitly pass --raw for debug mode.
$ veil get myapp/api-key
{"ok":false,"error":{"code":82,"message":"get refuses to print secret to stdout by default -- use -o <file> or --raw (debug only)"}}
$ veil get myapp/api-key -o /tmp/secret
{"ok":"true","path":"myapp/api-key","written_to":"/tmp/secret"}veil resolve returns an opaque sealed token, not the raw value:
$ veil resolve veil://myapp/api-key
{"sealed":"VEIL_SEALED:eyJ...","ok":"true"}The agent gets a token it can pass around but cannot decrypt. Only the holder of the seal private key can open it.
Fix 3: Sealed mode is the only mode
There is no plaintext mode in veil. resolve always returns VEIL_SEALED:... tokens. There's no configuration to forget, no flag to set, no way to accidentally send a raw secret through the resolve path.
The sealed tokens are encrypted with X25519 ECDH plus HKDF-SHA256 derived keys. The secret path and ref are bound into the envelope to prevent relabeling attacks — you can't take a sealed token for veil://app/db-password and claim it's for veil://app/api-key.
The crypto
Veil uses AES-256-GCM envelope encryption. A master KEK wraps per-secret DEKs (data encryption keys). Compromising one DEK exposes only one secret. Rotating the KEK only requires re-wrapping DEKs, not re-encrypting all secrets.
No custom cryptography. All primitives are machin builtins: aes_gcm_encrypt/decrypt, x25519_pub/shared, hkdf_sha256, rand_bytes, sha256_bytes. The crypto is boring on purpose.
Smoke tested in production code
I tested veil with grepapi — a real project that uses Stripe and Peage API keys. The agent runs grepapi's billing service with secrets managed by veil:
veil exec --env STRIPE_SECRET_KEY=veil://grepapi/stripe-secret-key \ --env PEAGE_MERCHANT_KEY=veil://grepapi/peage-merchant-key \ -- grepapi serve 8787
The billing service gets the real keys in its environment. It can process payments. But if it logs the key — in a debug line, in an error message — the agent sees ***.
I verified: the raw secret never appears in stdout, stderr, JSON output, sealed tokens, or the encrypted store file. 28 smoke tests, 28 passed, 0 leaks.
Agent-first CLI
Veil follows the cli-output-spec: stdout is data only (JSON by default), stderr is context, semantic exit codes, guide command explains the mental model, help-json exposes the machine-readable command catalog. Non-interactive by default.
Single ~82 KB static binary. No runtime. No dependencies. Built with machin.
The lesson
The lesson isn't about Phoenix specifically. It's about the threat model. When you give a secret to an agent, the secret enters the model's context window. The model sees it. The transcript preserves it. If the model is a cloud API, the secret leaves your machine.
Traditional secret managers protect secrets at rest and in transit. They don't protect them in the model's context. That's the gap veil fills.
Output scrubbing is mandatory, not optional. No raw output by default. Sealed mode is the only mode. These aren't features — they're the threat model.
veil on GitHub · docs · changelog
Next in this series: How veil's sealed response mode works with MCP — and why the agent never needs to decrypt the secret.