Your agent's safety gate guards an adapter, not the token
A safety gate inside one tool adapter only binds callers of that adapter. Any route holding the same token skips it. Here's how to find the ungated routes in yours.
On 2026-10-04 I logged a gotcha against a scheduled skill of mine. Every night at 21:00 it takes the day’s blog post and drafts social copy from it: a thread, a forum comment, a pitch. It is supposed to stop at drafts. The README for that code, at line 91, says so in bold: “Both skills are draft-only. Neither has a send path, and both prompts forbid claiming one.” The gotcha said a side effect we had gated ran anyway. It went through a route that read the same credential as the gated adapter, and that route never touched the gate.
That sentence is all I wrote down, and I have to be straight about what it leaves out. The entry doesn’t name the side effect, the file the route lived in, or how I noticed. I’m not going to rebuild those from memory for a blog post. What I can show you is why the log was no help. Here is the scheduler’s whole record of that skill’s run the next night:
2026-10-05 21:01:36|scheduler|[scheduler] Ran task "skill:growth-repurpose" (task_id:66): ok (skill_id=146, 4460 chars)
That’s the entire record. It shows ok and a character count. Nothing records whether an approval was requested, granted or skipped, because the gate only writes a line when someone calls it. A route that never calls it leaves no denial and no prompt, just a clean ok. My README’s sentence was true of the adapter and false of the token, and nothing in my logs could tell those two apart.
I had believed it for weeks. My mental model was “the gate is in the tool that sends, so sending is gated.” What I actually had was “sending through this tool is gated.”
Three earlier misses were the same mistake one level up
When I searched my notes, I found three earlier incidents that looked like this one. They aren’t the same bug, and I had been counting them as if they were. None of them involved a second reader of a credential. In each one, the check sat inside one caller instead of at the single point every caller passes through. Moving a token wouldn’t have fixed any of them.
- 2026-08-24: a parallel executor ran tools through its own loop instead of through the dispatcher that held the checks. Three protections were skipped at once: a hold on state-changing calls, pre-call hooks, and duplicate-write suppression.
- 2026-09-10: write protection ran only when the engine invoked a tool itself, before the model took over. Once the model was choosing and calling tools, writes went through unprotected.
- 2026-09-28: a request router built its client from an older code path that never reached the function where the gate lived.
The fix each time was to move the check down to the chokepoint, the one function every call has to go through. That works until a route can skip the chokepoint entirely, and a credential sitting in a shared environment is how you skip it. The October incident is that case. The first three are the sibling class, and they taught me the wrong lesson: “find the chokepoint.” With a readable key, the real chokepoint is whoever holds the key.
Wrapping the side effect still leaves the key in the environment
The standard advice is good and I follow it. The dev.to guardrails piece says the function that actually sends should check for approval, so that “the model never gets a code path that skips the check.” It also says a gate is only as strong as the narrowest path to the side effect. Both are correct. Neither covers the case where I write the second code path months later, for a different feature, using the same .env. Wrapping the send function doesn’t stop someone from building a new client.
Mark Laursen is closer to right: put a proxy in front of the API and never let agents hold real keys. His threat model is an agent that loses its instructions or makes a direct HTTP call on its own. Mine was more boring. No agent misbehaved. A route I wrote myself inherited a credential from a shared environment. The proxy fixes my case too, but only if the key actually moves. A proxy running next to a key that is still in os.environ is just one more adapter.
Find every process that can read your write token
This is one bug class: authorization enforced in a client wrapper instead of at the credential or API boundary. It shows up as LangChain tools that share one process environment, MCP servers wrapping a third-party API whose token the host also holds, Zapier or n8n flows reusing one OAuth connection, and microservices that check permissions in an SDK while every service runs on the same service account.
Invariant: a credential that can cause a side effect is readable only by the component that enforces the gate on that side effect. Any codebase either satisfies this or it doesn’t.
Here’s the check. Swap in your own gate name and secret patterns.
# 1. Static: files that load secrets, and whether the gate name appears in the same file.
GATE='require_approval' # your gate function
PAT='(TOKEN|API_KEY|SECRET)'
rg -l \
-e "process\.env\.[A-Z_]*$PAT" -e "os\.environ.*$PAT" -e "getenv\(.*$PAT" \
-e "load_dotenv\(" -e "dotenv_values\(" -e "require\(['\"]dotenv" -e "from ['\"]dotenv" \
-e "dotenv\.config\(" -e "\bconfig\.[a-z_]*(token|key|secret)" \
-e "get_secret_value|getSecretValue|SecretClient|secretmanager|keyring\.get_password" \
-e "security find-generic-password" . |
while read -r f; do
rg -q "$GATE" "$f" && echo "mentions-gate $f" || echo "UNGATED $f"
done
# 2. Runtime: which of your processes can see a secret-shaped variable? Prints names, not values.
for p in $(pgrep -u "$USER"); do
if [ -r "/proc/$p/environ" ]; then # Linux
hits=$(tr '\0' '\n' < "/proc/$p/environ" | rg -o "^[A-Z_]*$PAT[A-Z_]*=" | tr '\n' ' ')
else # macOS
hits=$(ps eww -o command= -p "$p" 2>/dev/null | tr ' ' '\n' | rg -o "^[A-Z_]*$PAT[A-Z_]*=" | tr '\n' ' ')
fi
[ -n "$hits" ] && echo "$p $(ps -o comm= -p "$p"): $hits"
done
# 3. Scope probe, no side effects. Set T to the VALUE of a variable step 2 named,
# read from that process's environment or from the .env it loaded. Pick the one for your API:
curl -sI -H "Authorization: Bearer $T" https://api.github.com/user | grep -i x-oauth-scopes
curl -s -H "Authorization: Bearer $T" https://slack.com/api/auth.test -D - -o /dev/null | grep -i x-oauth-scopes
curl -s "https://oauth2.googleapis.com/tokeninfo?access_token=$T" # read the "scope" field
Pass: step 1 prints no UNGATED lines, step 2 lists no process other than the one that enforces your gate, and step 3 lists no write scope for any token a non-gate process can see. Fail: any UNGATED file, any extra process in step 2, or a write scope in step 3 (for example repo or public_repo on GitHub, chat:write on Slack, or a Google scope without .readonly). A token with write scope in a process that doesn’t enforce the gate means your gate is documentation. Step 3 never sends a write request, so it’s safe to run against production tokens. Run it in a shell that doesn’t save history.
Know what each step misses:
- Step 1 is a co-occurrence heuristic, and it errs in both directions. A
mentions-gateresult only means the gate’s name appears somewhere in the file. It doesn’t mean the gate sits on the path from the secret to the call, so a file can pass while still having an ungated route. In the other direction, a file that gets its client from a factory, or from a config object loaded somewhere else, never matches, which is how routes like my 09-28 router get past a grep. Doing this properly means following call sites from each client constructor, and grep can’t do that. Read step 1 as a list of places to look, not a verdict. - Step 2 is the step that can’t be fooled by how the client was built, because the environment doesn’t care. But it only sees environment variables. A secret read from a file, a keychain or a secrets manager at call time won’t show up, and that’s what step 1’s loader patterns are for. On macOS,
ps ewwwon’t show the environment of processes owned by another user, or of SIP-protected ones, so an empty result there proves nothing about those processes. On Linux,/proc/<pid>/environshows the environment the process started with, not later changes. - Step 3 only covers token types that report their scopes. GitHub fine-grained tokens don’t return
x-oauth-scopes, so check those in the token settings page instead.
Move the token, not the check
The fix that held wasn’t one more check. It was taking the key away from everything except the one place that asks for approval. Here’s the shape, generically:
BEFORE AFTER
.env: API_TOKEN=... key holder (own OS user, or the only
├─ adapter A → gate → API reader of a vault entry)
├─ runner B → API (no gate) ├─ listens on /run/keyholder.sock (mode 0660, group "callers")
└─ skill C → API (no gate) ├─ peer = getpeereid()/SO_PEERCRED on each connection
├─ policy[peer]: read → forward; write → gate → forward
└─ only process that can read API_TOKEN
adapter A, runner B, skill C → socket → holder → API
.env: no API_TOKEN
Three details make this a control and not just another adapter.
- Where the key lives. It sits in a process that runs as a different OS user, or in a secrets store that only that user can read. Your agent’s user can’t read the file, the vault entry or the process environment. Step 2 should come back empty for every process except the holder.
- How callers authenticate. They connect over a unix socket. The holder asks the kernel who the peer is (
SO_PEERCREDon Linux,getpeereidon macOS and BSD). That identity comes from the OS, not from anything the caller presents. That’s why it isn’t just a new key in the environment: nothing gets copied into a.env, so nothing can leak to the next route that loads one. If you need a network hop and must hand out per-caller tokens, give each token a narrow scope, make it short-lived, and make sure the holder can revoke it. A leaked one then gets you a single caller’s read access, not the API. - Read versus write. The holder’s policy is per caller and per operation. Reads go straight through. Writes go through the gate. A route I add next month gets no access until I give it an entry, which turns “I forgot to call the gate” into “it got a permission error.”
I’d love to show you the check’s output before and after, something like “UNGATED 3 → 0.” I didn’t keep it, so I won’t make up the numbers. Run it on your own tree, where the numbers will be real.
The rule I’d apply to any codebase: if you can name the function that enforces your gate but you can’t name the only process holding the key, you have a caption, not a control.