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

> Source: <https://dev.to/enjoy_kumawat/my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-api-keys-sitting-4opj>
> Published: 2026-08-14 03:48:36+00:00

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.
