I gave Claude Code shell access to a side project last spring and watched it debug a failing OpenAI call the way any of us would: it ran cat .env. The key was now in the conversation, in the provider's logs, and in the editor's session history on disk. Nothing malicious happened. That is exactly the problem: nothing has to go wrong for a plaintext secret to travel once an agent can read files and run commands.
I ended up building a small open-source tool to fix this for myself. This post is mostly about the problem and the options, because the tool only makes sense once you agree on what is broken.
Why .env stopped being fine
A .env file was a reasonable compromise when the only reader was your own process. It is plaintext, but it is gitignored and nothing else looks at it.
Agents break that assumption in four boring ways:
- They read it into the prompt. Debugging a 401? The agent opens the file. Now the value is in the transcript.
-
They commit it.
git add -Aafter editing.gitignore, or building.env.exampleby copying the real file without blanking values. -
It shows up in tool output.
printenv, a test that prints its config, a debug log with request headers. Stdout goes back to the model. -
It lands in a bug report. Agents draft great GitHub issues. Stack traces with
Authorizationheaders included.
export OPENAI_API_KEY=... in .zshrc is worse, not better. Every process you start inherits it, including the agent and everything it spawns. You replaced a file the agent might read with an environment it definitely has.
What the OS keychain actually gives you
Every desktop OS ships an encrypted credential store, and each one solves a different problem than a file does:
- macOS Keychain encrypts items with a key derived from your login password. Each item has an access control list, and another app asking for it triggers a prompt.
- Windows Credential Manager uses DPAPI, tied to your Windows account. A copy of the disk without your credentials cannot decrypt it.
- Linux Secret Service (GNOME Keyring, KDE's ksecretservice, KeePassXC) stores an encrypted collection that unlocks with your login via PAM.
Be honest about the limit: none of this stops a process running as you from asking for a value. What it stops is every accidental path above. Nothing to cat, nothing to commit, nothing in printenv. The plaintext exists only in the memory of the process that asked.
The options, without the marketing
| Option | Encrypted at rest | Visible to an agent with a shell | Good for |
|---|---|---|---|
.env |
No | cat .env |
Throwaway prototypes, test keys |
| Shell profile export | No | printenv |
Nothing real |
| direnv | No (.envrc is plaintext) |
Yes, once you cd in |
Per-project config, not secrets |
| 1Password / Bitwarden CLI | Yes | Only via op read after sign-in |
Teams already on the vault |
| Cloud secret manager | Yes | Only via SDK with cloud creds | Production runtime |
| OS keychain via a CLI | Yes | Only via an explicit, gated call | Dev machines running agents |
direnv gets recommended a lot here and it is a great tool for the wrong job. Its exports become ambient in every process started from that directory, which is the property you are trying to remove.
Password-manager CLIs are a fine answer if your team already runs one. Cloud managers are the right home for production secrets and the wrong tool for the key your editor needs on a laptop.
The keychain sits in the middle: local, encrypted, already installed, no account. What it lacks is one interface across three operating systems and any concept of an agent.
The part that matters: how the value reaches the agent
This is the design decision I got wrong first. Storage is the easy half. The real question is: which tool call does the agent make, and does the value pass through the model?
With q-ring wired in as an MCP server, the agent gets tools instead of a file:
-
exec_with_secretsruns a command with the secret injected into the child's environment, captures stdout/stderr, redacts any known secret value from the output, and returns the exit code plus scrubbed logs. The model seesExit code: 0and the migration output, never the connection string. One honest limit: the scrubber matches verbatim values only. A key the child has base64-encoded, hex-dumped or printed in part passes through; widening it to encoded forms and 16-character slices is tracked in q-ring #121 and #122. The default restricted profile also refuses to runcurl,python,shand other binaries that could forward the environment somewhere else. -
has_secret,inspect_secretandlist_secretsanswer "does it exist" and "when does it expire" with metadata only. -
get_secretdoes return the value, because sometimes the agent genuinely needs it. But it is a deliberate call, it goes through key-level policy first (deny lists, or--requires-approvalkeys that wait for a humanqring approve KEY --for 900), and every read lands in a hash-chained audit log stamped with the client name.
The honest limit again: once get_secret has handed a value to a client that also has a network tool, no local secrets manager can take it back. So production keys get denied to get_secret and exposed only through exec_with_secrets.
Five minutes to switch
pnpm add -g @i4ctime/q-ring # or brew install i4ctime/tap/qring
qring doctor # confirms the keychain is reachable
qring set OPENAI_API_KEY # prompts on stderr, never echoes, never in shell history
qring import .env --skip-existing && rm .env
qring exec -- pnpm test # injects into the child, redacts output
qring setup claude # or: cursor, kiro; writes the MCP config
Then give keys a lifetime (--ttl), check qring audit --key OPENAI_API_KEY when something feels off, and qring validate KEY before you spend an hour debugging a key that was rotated last week.
Where it does not fit
-
Headless Linux / SSH has no keyring daemon. You can wrap commands in
dbus-run-sessionwithgnome-keyring-daemon, or opt into an encrypted file backend (QRING_BACKEND=file, AES-256-GCM, never activates on its own). -
CI should use the CI system's own secret store. q-ring is useful there only for
qring ci:validate, which checks declared secrets are present and live.
The point
The threat is not a clever attacker. It is a helpful assistant doing exactly what you asked, with a filesystem full of plaintext. Move the plaintext somewhere that requires an explicit, logged call, and route the common case through a path where the model never sees the value at all.
q-ring is AGPL-3.0, no account, no cloud, no telemetry. Full write-up with the Linux/Windows specifics is in the guide on the site, and the MCP setup docs cover Cursor, Kiro, Claude Code and VS Code. Source is on GitHub. If you think the threat model is wrong, I would genuinely like to hear where.
Drafted with help from an AI assistant and checked against the q-ring source and docs by me.
Top comments (6)
Good table. One more leak path to add once the agent gets computer use, i.e. it can click and type in real apps: the screen itself. Screenshots and accessibility-tree reads go back to the model, so a key that is merely visible (a settings page, a password manager window left open, a terminal with an earlier
echo) leaks the same way ascat .env. And when the agent logs into a site for you, the obvious design passes the password as a tool-call argument, so it ends up in the transcript anyway.The keychain idea carries over: the agent should ask for a credential by name, and a local process types the value straight into the field without it ever coming back as a tool result. Pinning each saved login to the origin it was first used on also helps, so a prompt-injected page can't ask for the bank password.
Disclosure: I'm on the Auten team (computer-use MCP), and that's how we handle logins there. Your point about a process running as you still applies, though: if the agent also has an unrestricted shell, the client's permission rules are the real boundary.
Agreed on all three: the screen is cat .env with extra steps, a password as a tool argument is in the transcript by design, and typing by name from a local process is the same keychain argument carried into computer use. Origin pinning per saved login is the piece I had not written down. Both are now on the q-ring board: github.com/I4cTime/q-ring/issues/124 (type_secret) and github.com/I4cTime/q-ring/issues/125 (origin pinning). The shell caveat still stands though: if the agent has an unrestricted shell, the client's permission rules are the boundary, not the vault.
Nice, thanks for opening those. One detail worth adding to #125: check the origin at the moment of typing, not when the task starts. Pages redirect, and a login flow can bounce through an SSO domain or a lookalike in between, so compare the URL of the focused tab right before the value goes in, and stop if focus moves to another window mid-type.
Agreed on the shell point. The vault only covers the paths that go through it.
Added to #125, with the mid-type abort on #124 as well: origin read from the focused tab right before the first character, re-checked while typing, stop and report the count if focus moves. The redirect-through-SSO case is exactly the one a call-time check would miss. Thanks.
The distinction between ambient environment variables and injected execution is the part most setups get backwards. We used to keep keys in a local shell rc, until an agent running a curl repro piped the entire environment into a pastebin log.Redacting output in the wrapper is a solid defense, but the tricky edge is partial reads: an agent inspecting a 500 trace that contains a base64-encoded chunk of a key or a sliced token. If the scrubber only looks for the verbatim secret string, it passes straight through to the context window.
You are right, and the code comment already admits it: the scrubber matches verbatim values only. Encoded forms (base64 at all three alignments, hex, URL-encoded) are a cheap addition, and sliced tokens can be caught by matching any 16+ character window of a secret. Both are on the board now: github.com/I4cTime/q-ring/issues/121 and github.com/I4cTime/q-ring/issues/122. I should have said the limit in the post; it is in there now.