Your coding agent can read .env. Here's where the API keys should live instead.
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 e
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. 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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.