My claude -p Call Has a --safe-mode Flag for Isolation. It Never Isolated the Two API Keys Sitting Right Next To It. A developer discovered that Claude Code's --safe-mode flag, intended to isolate subprocess calls from project configuration, does not prevent environment variable leakage. The developer's git_commit.py and server.py subprocess calls to 'claude -p' inherited the parent process's full environment, including GITHUB_TOKEN and DEV_TO_API credentials, because no env= argument was passed to subprocess.check_output. A minimal repro confirmed both credentials were visible to the subprocess, highlighting a security gap in the isolation mechanism. Four days ago I fixed a real problem in this repo: a bare claude -p invocation from git commit.py was loading this project's own CLAUDE.md into a one-shot commit-message completion that had no use for it — including a "MANDATORY routing rules" block instructing the model to call MCP tools that don't exist in this environment. The fix was --safe-mode , a flag that drops CLAUDE.md/skills/plugins/hooks/MCP-server auto-discovery while leaving the OAuth session this project's whole reason for shelling out to claude -p instead of hitting the Anthropic API directly — see decisions.md ADR-004 intact. I verified it live with a diagnostic prompt, watched the answer change from YES-SAW-CTX-RULES to NO-CTX-RULES , and moved on satisfied that the subprocess call was isolated. It wasn't. --safe-mode isolates config discovery — which files and servers claude goes looking for. It says nothing about the one thing every subprocess call gets by default whether you ask for it or not: the parent process's environment variables. Here's git commit.py 's claude -p invocation, the version that shipped with the --safe-mode fix: raw = subprocess.check output "claude", "-p", "--safe-mode", SYSTEM + "\n\n" + diff , text=True, timeout=20, stderr=subprocess.PIPE, .strip No env= argument. server.py 's twin call in claude had the identical shape. When subprocess.check output gets no env= kwarg, Python doesn't start the child with a clean slate and hand it back nothing — it copies the entire current process's os.environ into the child. That's the default, documented behavior, and it's usually exactly what you want: PATH, HOME, whatever the child needs to actually run. The problem is what else is sitting in this process's environment by the time claude gets called. server.py 's load env runs at import time and does os.environ.setdefault k, v for every line in .env — which means GITHUB TOKEN scoped repo, user , per key facts.md — full write access to every repo this account can touch and DEV TO API are both sitting in os.environ for the entire lifetime of the process, available to anything that process spawns. claude 's only job is turning a diff string into a Conventional Commit message. It has never needed either credential. It gets them anyway, because nothing ever told the subprocess call not to. I didn't want to trust my own reasoning about default subprocess behavior, so I built the smallest repro that actually exercises the real call shape. A stand-in for the claude binary that reports what it can see: python fake claude.py import os, sys seen = k for k in "GITHUB TOKEN", "DEV TO API" if k in os.environ print "SAW CREDENTIALS:" + ",".join seen if seen else "SAW CREDENTIALS:none", file=sys.stderr print "fix: stub commit message" And the exact call shape from git commit.py , with fake credentials set the same way load env sets real ones: os.environ "GITHUB TOKEN" = "ghp FAKE FULL WRITE TOKEN FOR REPRO" os.environ "DEV TO API" = "fake devto api key for repro" proc = subprocess.run sys.executable, "fake claude.py", "-p", "--safe-mode", "x" , capture output=True, text=True, print proc.stderr.strip SAW CREDENTIALS:GITHUB TOKEN,DEV TO API Both credentials, visible to a process whose entire job is completing one line of text from a diff. --safe-mode was on the command line the whole time. It didn't matter, because it was never the layer that controlled this. This repo already has an honest, unshipped proposal sitting in a comment thread about splitting server.py into two processes so a compromise of one credential domain doesn't automatically expose the other — the article stops at sketching it, doesn't build it, and every reply I've drafted since has said so plainly. That finding is about two credentials coexisting inside the same Python process , both reachable by any of that process's own 8 tools regardless of which one actually needs which key. This is a different failure surface. It's not about two tools in one process — it's about a process boundary that actually gets crossed, into a genuinely separate binary, and finding that the boundary carries more authority across it than anyone decided to send. --safe-mode reads like an isolation flag, and for what it does control, it is one. Ambient environment variables are a completely orthogonal axis that no flag on that command line ever touched, and nothing about the flag's name would tip you off to that gap unless you went and checked what a real subprocess call actually inherits by default. Small and targeted — I don't want to hand-build an allowlist of everything claude -p needs PATH, HOME, whatever else its OAuth session lookup depends on that I don't have full visibility into from inside this sandbox , so I excluded exactly the two credentials this repo's own .env manages, rather than trying to reconstruct a minimal environment from scratch: CLAUDE SUBPROCESS ENV EXCLUDE = "GITHUB TOKEN", "DEV TO API" def claude subprocess env : return {k: v for k, v in os.environ.items if k not in CLAUDE SUBPROCESS ENV EXCLUDE} Wired into both call sites: raw = subprocess.check output "claude", "-p", "--safe-mode", SYSTEM + "\n\n" + diff , text=True, timeout=20, stderr=subprocess.PIPE, env= claude subprocess env , .strip Reran the identical repro against the fixed code: AFTER FIX -- child process env visibility: SAW CREDENTIALS:none PATH PRESENT:True Neither key reaches the child; PATH and everything else the real claude binary needs to run and find its own OAuth session still does, because I only subtracted two specific names instead of replacing the whole environment. Both files got the fix — server.py 's claude and git commit.py 's inline call — since they share the identical call shape and this repo has a well-documented history of a fix landing in one twin and not the other. I added a regression case to both --selftest blocks asserting the two credential keys are absent from the built environment while PATH survives. git commit.py --selftest passes. server.py --selftest can't import directly in this sandbox no mcp package, a pre-existing, documented limitation — verified with the same FastMCP -stub-on- PYTHONPATH technique this repo's own audits already use; the full selftest block, new cases included, passes there too. The generalizable version of this: a flag that isolates one category of ambient context config files, discovered servers, hooks is not evidence that a subprocess call is isolated, full stop. subprocess.check output has its own default behavior for environment variables, completely independent of whatever CLI flags you pass to the program it's launching, and that default is "inherit everything." If a call site loads secrets into os.environ anywhere upstream, every subprocess spawned after that point gets them for free unless something explicitly says otherwise — regardless of how isolated the command's own flags make it look.