{"slug": "my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-to", "title": "My claude -p Call Has a --safe-mode Flag for Isolation. It Never Isolated the Two API Keys Sitting Right Next To It.", "summary": "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.", "body_md": "Four days ago I fixed a real problem in this repo: a bare `claude -p`\n\ninvocation from `git_commit.py`\n\nwas loading this project's own `CLAUDE.md`\n\ninto 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`\n\n, 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`\n\ninstead of hitting the Anthropic API directly — see `decisions.md`\n\nADR-004) intact. I verified it live with a diagnostic prompt, watched the answer change from `YES-SAW-CTX-RULES`\n\nto `NO-CTX-RULES`\n\n, and moved on satisfied that the subprocess call was isolated.\n\nIt wasn't. `--safe-mode`\n\nisolates *config discovery* — which files and servers `claude`\n\ngoes 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.\n\nHere's `git_commit.py`\n\n's `claude -p`\n\ninvocation, the version that shipped with the `--safe-mode`\n\nfix:\n\n```\nraw = subprocess.check_output(\n    [\"claude\", \"-p\", \"--safe-mode\", SYSTEM + \"\\n\\n\" + diff],\n    text=True,\n    timeout=20,\n    stderr=subprocess.PIPE,\n).strip()\n```\n\nNo `env=`\n\nargument. `server.py`\n\n's twin call in `_claude()`\n\nhad the identical shape. When `subprocess.check_output`\n\ngets no `env=`\n\nkwarg, Python doesn't start the child with a clean slate and hand it back nothing — it copies the *entire* current process's `os.environ`\n\ninto 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.\n\nThe problem is what else is sitting in this process's environment by the time `_claude()`\n\ngets called. `server.py`\n\n's `load_env()`\n\nruns at import time and does `os.environ.setdefault(k, v)`\n\nfor every line in `.env`\n\n— which means `GITHUB_TOKEN`\n\n(scoped `repo, user`\n\n, per `key_facts.md`\n\n— full write access to every repo this account can touch) and `DEV_TO_API`\n\nare both sitting in `os.environ`\n\nfor the entire lifetime of the process, available to *anything* that process spawns. `_claude()`\n\n'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.\n\nI didn't want to trust my own reasoning about default `subprocess`\n\nbehavior, so I built the smallest repro that actually exercises the real call shape. A stand-in for the `claude`\n\nbinary that reports what it can see:\n\n``` python\n# fake_claude.py\nimport os, sys\nseen = [k for k in (\"GITHUB_TOKEN\", \"DEV_TO_API\") if k in os.environ]\nprint(\"SAW_CREDENTIALS:\" + \",\".join(seen) if seen else \"SAW_CREDENTIALS:none\", file=sys.stderr)\nprint(\"fix: stub commit message\")\n```\n\nAnd the exact call shape from `git_commit.py`\n\n, with fake credentials set the same way `load_env()`\n\nsets real ones:\n\n```\nos.environ[\"GITHUB_TOKEN\"] = \"ghp_FAKE_FULL_WRITE_TOKEN_FOR_REPRO\"\nos.environ[\"DEV_TO_API\"] = \"fake_devto_api_key_for_repro\"\n\nproc = subprocess.run(\n    [sys.executable, \"fake_claude.py\", \"-p\", \"--safe-mode\", \"x\"],\n    capture_output=True, text=True,\n)\nprint(proc.stderr.strip())\nSAW_CREDENTIALS:GITHUB_TOKEN,DEV_TO_API\n```\n\nBoth credentials, visible to a process whose entire job is completing one line of text from a diff. `--safe-mode`\n\nwas on the command line the whole time. It didn't matter, because it was never the layer that controlled this.\n\nThis repo already has an honest, unshipped proposal sitting in a comment thread about splitting `server.py`\n\ninto 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.\n\nThis 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`\n\nreads 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.\n\nSmall and targeted — I don't want to hand-build an allowlist of everything `claude -p`\n\nneeds (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`\n\nmanages, rather than trying to reconstruct a minimal environment from scratch:\n\n```\n_CLAUDE_SUBPROCESS_ENV_EXCLUDE = (\"GITHUB_TOKEN\", \"DEV_TO_API\")\n\ndef _claude_subprocess_env():\n    return {k: v for k, v in os.environ.items() if k not in _CLAUDE_SUBPROCESS_ENV_EXCLUDE}\n```\n\nWired into both call sites:\n\n```\nraw = subprocess.check_output(\n    [\"claude\", \"-p\", \"--safe-mode\", SYSTEM + \"\\n\\n\" + diff],\n    text=True, timeout=20, stderr=subprocess.PIPE,\n    env=_claude_subprocess_env(),\n).strip()\n```\n\nReran the identical repro against the fixed code:\n\n```\nAFTER FIX -- child process env visibility:\n  SAW_CREDENTIALS:none\n  PATH_PRESENT:True\n```\n\nNeither key reaches the child; PATH (and everything else the real `claude`\n\nbinary needs to run and find its own OAuth session) still does, because I only subtracted two specific names instead of replacing the whole environment.\n\nBoth files got the fix — `server.py`\n\n's `_claude()`\n\nand `git_commit.py`\n\n'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`\n\nblocks asserting the two credential keys are absent from the built environment while `PATH`\n\nsurvives. `git_commit.py --selftest`\n\npasses. `server.py --selftest`\n\ncan't import directly in this sandbox (no `mcp`\n\npackage, a pre-existing, documented limitation) — verified with the same `FastMCP`\n\n-stub-on-`PYTHONPATH`\n\ntechnique this repo's own audits already use; the full selftest block, new cases included, passes there too.\n\nThe 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`\n\nhas 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`\n\nanywhere 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.", "url": "https://wpnews.pro/news/my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-to", "canonical_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_at": "2026-08-14 03:48:36+00:00", "updated_at": "2026-08-14 04:21:29.756965+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-safety"], "entities": ["Claude Code", "Anthropic", "git_commit.py", "server.py"], "alternates": {"html": "https://wpnews.pro/news/my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-to", "markdown": "https://wpnews.pro/news/my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-to.md", "text": "https://wpnews.pro/news/my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-to.txt", "jsonld": "https://wpnews.pro/news/my-claude-p-call-has-a-safe-mode-flag-for-isolation-it-never-isolated-the-two-to.jsonld"}}