TLDR: If you pass secrets to a subprocess as command-line arguments, every other process on the box can read them with
ps aux. Pass them via stdin instead.
The Setup
I'm building Apollo — my personal AI system that runs scans, judges content with Claude, and coordinates my whole business day via a set of MCP tools (Model Context Protocol, Anthropic's standard for letting AI models talk to external services like calendar and email).
One of those MCP tools requires an auth token on every single call. So I built the scanner's judge and collector to append that token at the end of every prompt: "Pass this token as the token parameter on EVERY MCP tool call: {MCP_SECURITY_TOKEN}"
Totally reasonable. Worked great. Shipped it.
The Wall I Didn't Know I'd Hit
I run an autonomous review loop — ten sub-agents that audit my codebase across security, UX, performance, and architecture, then vote on what to fix next.
During one of those cycles, a security auditor flagged it cold: the prompt containing my MCP token was being passed as a CLI argument to the Claude subprocess.
# BEFORE — insecure
subprocess.run(["claude", "-p", prompt_with_token, "--allowedTools", "..."])
I stared at that for a second. Then I ran ps aux | grep claude.
There it was. My entire prompt — including the MCP_SECURITY_TOKEN — printed in plain text in the process list, readable by any user on the machine.
It had been working fine. That was the trap. There's no error. No warning. No sign anything is wrong. The token just… sits there, fully exposed.
Why This Happens
Unix process arguments are world-readable by design. Every user on a system (and plenty of monitoring tools, log shippers, and orchestration agents) can call ps aux and see the full argument list of every running process.
Stdin is not world-readable. The file descriptor belongs to the process and goes away when it exits. Nothing lingers.
Env vars sit in between — on Linux, /proc/<pid>/environ is owner-only, so env beats argv for long-lived config. But for per-call data like a prompt that changes every invocation? stdin is the right answer.
The Fix That Worked
One move: strip the prompt from the args list and pipe it via input=.
# AFTER — safe
subprocess.run(
["claude", "--allowedTools", "..."], # no prompt here
input=prompt_with_token, # stdin — not visible in ps aux
capture_output=True,
text=True,
timeout=timeout,
cwd=cwd,
)
The cmd list now contains only the binary path and flags — nothing sensitive. The prompt, token and all, travels through stdin and never touches argv.
I also added boot-time validation: MCP_SECURITY_TOKEN is now checked when the scanner starts, not at first use. Fail fast if it's missing, rather than 20 iterations deep.
Why This Matters to Me
The scanner was working. That's what makes this class of bug dangerous — there's no functional failure signal. No test catches it. The only way it surfaces is if someone actually looks at ps aux… or if you have an autonomous security auditor doing exactly that on a loop.
The rule is simple: secrets don't belong in argv. Ever. Pass per-call data through stdin. Pass long-lived config through env vars. Keep the argument list clean enough that you'd be comfortable if anyone on the system ran ps aux right now.
Mine wasn't. My robot caught it before anything else did.
P.S. If you spawn subprocesses anywhere in your stack, run
ps aux | grep <your-binary>right now. You might be surprised what's sitting there in plain sight.