DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My claude -p Call Has a --safe-mode Flag for Isolation. It Never Isolated the Two API Keys Sitting Right Next To It.

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.

What the call actually looks like

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()
Enter fullscreen mode Exit fullscreen mode

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.

Proving it, not just reading it

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:

# 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")
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode
SAW_CREDENTIALS:GITHUB_TOKEN,DEV_TO_API
Enter fullscreen mode Exit fullscreen mode

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.

Why this is a different bug than "MCP server holds two keys"

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.

The fix

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}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Reran the identical repro against the fixed code:

AFTER FIX -- child process env visibility:
  SAW_CREDENTIALS:none
  PATH_PRESENT:True
Enter fullscreen mode Exit fullscreen mode

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.

What I checked before calling it done

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.

Top comments (0)