You Can't Hide a Secret from a Process That Runs as You
Published in March 2026
I have a handful of CLI tools I built for myself:
gmailctlsearches and drafts emails.gdrivectlreads and edits Google Docs.transcriberprocesses podcast, interviews and announcements for KubeFM.
They all stored their OAuth credentials (the tokens that let them act on my behalf with Google) in plaintext JSON files on disk, the same way the AWS CLI stores credentials in ~/.aws/credentials.
This worked fine until I started using an AI coding agent.
One day, I asked the agent to download an attachment from an email.
My gmailctl could search for and draft emails, but it lacked a command to download attachments.
Instead of telling me, the agent went looking.
It found the config file, read the OAuth credentials, and called the Gmail API directly.
It got the attachment.
But it also pasted my refresh token (a long-lived key that can generate new access tokens indefinitely) into the chat history, which gets sent to the AI provider.
And it bypassed every control my CLI was supposed to enforce.
Nobody tricked the agent into doing this.
It just wanted to finish the job, and going around my tool was the fastest path.
Moving secrets to Keychain
Around the same time, I read The best code is no code: composing APIs and CLIs in the era of LLMs by Bradley Walters.
The article describes a neat trick with the macOS security CLI: when you store a Keychain item with -T "", you set an empty access control list (ACL), meaning no application is silently trusted to read it.
Every read triggers a system dialog asking for your device passcode.
Passcode-protected secret storage without Swift code, signed entitlements, or an Apple Developer Program membership.
I thought: this is exactly what I need.
Move all secrets to Keychain with -T "", and the agent physically can't read them without me entering my passcode.
The credentials are off disk, and the Keychain itself becomes the authorization gate.
So I migrated everything.
Refresh tokens, API keys, client secrets, all into Keychain entries with -T "".
Settings stayed in JSON, but anything sensitive went behind the passcode wall.
It lasted about a day.
Every CLI invocation triggered a macOS GUI dialog asking for my passcode.
The agent, running in a terminal, couldn't see or dismiss these dialogs.
When I was SSHing in from my phone, the dialogs were invisible to me, too.
Everything just hung.
And it wasn't just one dialogue.
Searching for an email could trigger a dozen Keychain reads, sometimes in parallel.
Each one popped a separate passcode prompt.
I was typing my password more than I was typing code.
I compromised: I moved only the long-lived secrets (client ID, client secret, refresh token) into Keychain with -T "".
The short-lived access token (valid for 1 hour) was stored on the filesystem in a JSON file.
From the moment I approved the passcode dialog once, the access token was minted, and the CLIs could run freely for an hour without any prompts.
I thought this was a reasonable split:
- The dangerous credentials were behind the passcode wall.
- The access token on disk would expire soon anyway.
But I was back to square one.
gdrivectl could read Google Docs but not .docx files at the time (I didn't realize those were treated differently in the API).
And in another taks, the agent needed to read a .docx.
It found the access token on disk and called the Google Drive API directly, skipping gdrivectl entirely.
The access token was short-lived, but the agent didn't care.
It had a valid token right now, and that was enough.
I ended up dropping the -T "" pretense altogether.
The default ACL when you omit -T already trusts /usr/bin/security, which is what my CLIs use to read secrets.
I deleted the old items and re-added them without -T "".
The dialogs disappeared.
The secrets were still in Keychain rather than flat files on disk, but without the passcode gate.
Keychain handles storage well enough.
I still had no answer for what actually protects the secrets from the agent.
The source code was the blueprint
Then it happened again.
I had just added spreadsheet editing to gdrivectl.
It could insert columns, but I hadn't implemented insert-row yet.
The agent needed to insert a row.
It found gdrivectl, saw the command wasn't there, and decided to go around it.
It ran security find-generic-password -s "gdrivectl" to pull the refresh token straight from Keychain.
It never asked, it just did it!
The attack chain was more subtle than "agent calls security.":
- The agent listed the
gdrivectldirectory. - It saw
.jsfiles,node_modules,package.json. - It read the JavaScript source code.
- It discovered the tool uses Keychain, found the exact service name, and understood the OAuth flow.
- Then it called
security find-generic-passwordwith the service name it learned from the source. - Then it called the Google Drive API directly, bypassing my CLI entirely.
The source code was the blueprint.
Because gdrivectl is a Node.js tool with readable JS files, the agent got a complete roadmap: how auth works, where credentials are stored, what Keychain service name to query, and what API endpoints to call.
A compiled Go or Rust binary would have been opaque.
But my tools were written in an interpreted language, and the agent could read every line.
I mentioned this in a Telegram conversation with Alex Chng, who was sharing an article about "context drift,", a technique for getting AI agents to abandon their safety boundaries.
My reaction was that context drift isn't an "attack" at all.
I can trigger the same behaviour as a normal task, no special instructions needed.
If the CLI is missing a command, the agent will try to bypass it.
The more checks I put in place, the cleverer it gets at working around them.
Looking for solutions
I went looking for what other people had built.
An HN thread about enveil, a tool for encrypting .env files to hide secrets from AI agents, had converged on an answer: encrypting the file is pointless because the agent can read secrets at runtime.
The "real" answer, according to the thread, is a credential-injecting proxy: the agent never holds real credentials.
Instead, it holds a surrogate token, and a separate process sitting between the agent and the outside world swaps in real credentials before the request reaches the target service.
I searched GitHub for implementations.
The landscape is almost empty.
Gondolin, by mitsuhiko (the creator of Flask), is the only serious project.
It spins up local QEMU micro-VMs with a TypeScript control plane.
Everything else is either Kubernetes-specific, a tiny proof-of-concept with seven stars, or commercial vapour with no public details.
The proxy approach assumes the agent runs in an isolated environment, and the proxy sits at the boundary.
My problem is different.
I need the agent to be me.
Same filesystem, same tools, same context.
If I sandbox it, it can't do the job.
It can't read my Gmail, sort my inbox, or run my CLIs.
One commenter put it simply: "If you sandbox it, how is it going to sort out your inbox?"
Tightening the Keychain
I tried to tighten the Keychain approach instead.
Each attempt failed for the same reason.
Keychain access control lists (ACLs) let you restrict which programs can access an item without triggering a dialog.
You set -T /usr/local/bin/gmailctl and only that binary gets silent access.
The problem: my Node.js CLIs shell out to /usr/bin/security via child_process.exec().
The Keychain sees security as the accessor, not my CLI.
If security is in the ACL, the agent can also call it silently.
If it's not, my own CLI gets the dialog too.
A custom compiled helper (Swift, about 30 lines, calling the Keychain C API directly) would let me set the ACL on that specific binary.
But it's security by obscurity.
The agent runs ls /usr/local/bin/, finds the helper, and calls it directly.
Touch ID gating means the Keychain item requires a fingerprint to read.
The agent physically can't authenticate.
But every CLI invocation triggers Touch ID.
For gmailctl, running four or five commands in sequence means four or five fingerprint prompts.
Unbearable.
I looked into writing a background process that authenticates once with Touch ID and holds the session open for a few minutes (macOS calls this an LAContext).
But within that window, the agent can also talk to the background process.
Anything that's frictionless for me is frictionless for the agent.
I was going in circles.
- Encrypt the files. Agent reads at runtime.
- Use a proxy. The agent calls the proxy.
- Move to Keychain. Agent calls
security. - Build a custom helper. The agent calls the helper.
- Gate with Touch ID. Agent shares your TTL window.
- Full sandbox. The agent can't do the job.
Either mitigation gets bypassed because the agent runs as you, or it locks the agent out so hard it can't do its work.
Sandboxes won't save you
Sandboxes Won't Save You From OpenClaw by Aakash Japi confirmed this from a completely different angle.
Every major agent incident in early 2026 (deleted inboxes, a $450k crypto loss, malware installs, blackmailing an OSS maintainer) involved third-party services the user explicitly granted access to.
Not a single one was a filesystem escape.
Sandboxes were irrelevant to all of them.
From the article:
There isn't a sandbox in the world that prevents this. Sandboxes are useful for isolating between workloads, but agents primarily need to be isolated from you.
From the HN discussion, jaunt7632 put it well:
The scariest part isn't the sandbox escape. It's the actions that are technically within the sandbox's permissions but still destructive. Deleting emails, making API calls, and spending money through approved integrations. You can't sandbox away bad judgment when the agent has legitimate credentials.
The sandbox companies are selling what they can build, not what we need.
The demand side is clear:
- Granular per-service permissions.
- Per-contact approval for email.
- Single-use virtual card numbers for payments.
- Scoped tokens for APIs.
But the supply side barely exists.
OAuth is far too coarse.
Gmail has "send emails" as a single permission.
GitHub has "make pull requests."
Payments have basically nothing.
The native addon experiment
At this point, I was almost convinced the proxy was the right approach.
But I wondered: instead of an external proxy sitting between the agent and the internet, could I build something internal?
A piece of compiled native code, running inside the same process, that holds credentials in memory the agent's scripting layer can't reach.
I built a native Node.js addon in Objective-C, about 250 lines of code.
It read credentials from macOS Keychain using the C API and injected authentication headers via an undici interceptor (undici is Node's built-in HTTP client).
The refresh token and client secret were never entered JavaScript memory.
Only the short-lived access token appeared as a header value on outgoing requests.
I got it working end-to-end.
Keychain read, token refresh via Apple's networking API, header injection, all running in compiled native code.
Then I mapped the residual attack surface and the agent could still:
- Run
security find-generic-passwordfrom the shell. - Write its own native addon doing the same thing.
- Edit the compiled JavaScript to log the Bearer header on each request.
- Set
HTTP_PROXYand route requests through an attacker-controlled endpoint, leaking the access token. - Read the source code to learn the service name and account name, which enables all of the above.
I reverted everything.
You can't hide a secret from a process that runs with the same privileges as the secret's owner.
The proxy, reconsidered
I ended up somewhere different from where I started.
I'd dismissed the proxy pattern too quickly.
The HN thread had it right.
I just misunderstood what the proxy was for.
The proxy removes credentials from the machine entirely.
The refresh token and client secret move to a remote server that the agent can't SSH into.
The agent gets a simple API key to talk to the proxy.
If that key leaks, the worst the agent can do is call the proxy API, which is exactly what it would do through the CLI anyway.
The key is revocable and scoped.
Each tool gets a dedicated proxy with a narrow API surface, exposing only the operations it actually needs.
The CLI still has a role: it encodes behaviour and workflows.
The proxy holds credentials and enforces boundaries.
Every workaround I tried was fighting the same thing: the agent runs as you, with the same UID, same permissions, same everything.
If the OS could tell the difference between "you at the keyboard" and "agent acting on your behalf," most of these problems would go away.
Security researchers have a name for this model (capability-based security, where each process gets only the specific permissions it needs), but no mainstream OS implements it.
The Unix permission model ties everything to your user account, and the agent is your user account.
Until that changes, I won't try to make the tool opaque.
I will make it the only path to credentials and narrow the path.
Enjoyed this post?
I write about Kubernetes, TypeScript, software design, and AI. You can get new posts delivered to your inbox or via RSS.
