Skip to content

fix: filter CLAUDECODE env var from subprocess environment#732

Merged
qing-ant merged 4 commits intomainfrom
fix/filter-claudecode-env-v2
Mar 26, 2026
Merged

fix: filter CLAUDECODE env var from subprocess environment#732
qing-ant merged 4 commits intomainfrom
fix/filter-claudecode-env-v2

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

Summary

When the Python SDK is used inside a Claude Code hook or plugin, the parent process has CLAUDECODE=1 in its environment. The SDK's SubprocessCLITransport.connect() inherited all of os.environ into the child subprocess, including CLAUDECODE. This caused the CLI subprocess to think it was running inside a Claude Code parent, which disables certain features and can cause unexpected behavior.

Changes

subprocess_cli.py

Filter out CLAUDECODE from the inherited environment before passing it to the subprocess:

inherited_env = {
    k: v for k, v in os.environ.items() if k != "CLAUDECODE"
}
process_env = {
    **inherited_env,
    "CLAUDE_CODE_ENTRYPOINT": "sdk-py",
    **self._options.env,
    "CLAUDE_AGENT_SDK_VERSION": __version__,
}

Users who explicitly need CLAUDECODE can still set it via options.env.

test_transport.py

Two new tests:

  • test_claudecode_env_var_not_inherited — verifies CLAUDECODE is filtered from the subprocess environment when present in os.environ
  • test_claudecode_can_be_set_via_options_env — verifies users can still explicitly set CLAUDECODE via ClaudeAgentOptions(env={"CLAUDECODE": "1"})

E2E Verification

Verified with a live SDK instance (v0.1.50, CLI 2.1.84):

Parent process CLAUDECODE: 1

  Received: SystemMessage
  Received: AssistantMessage
    Text: hello world
  Received: RateLimitEvent
  Received: ResultMessage

SUCCESS: SDK query completed with CLAUDECODE=1 in parent env

Test Results

All 305 unit tests pass. Ruff clean.

Supersedes #594 (which has merge conflicts).

Fixes #573

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward env var filtering with good test coverage.

Extended reasoning...

Overview

This PR modifies SubprocessCLITransport.connect() in subprocess_cli.py to filter the CLAUDECODE environment variable from the inherited os.environ before passing it to the child CLI subprocess. Two new tests in test_transport.py verify both the filtering behavior and the ability to explicitly re-set CLAUDECODE via options.env.

Security risks

No security concerns. The change reduces environment leakage (filtering out an env var that causes unintended behavior), which is a positive security direction. Users retain the ability to explicitly opt in via options.env.

Level of scrutiny

This is a low-risk, minimal change. The production diff is a 3-line dict comprehension plus a 2-line comment. The logic is straightforward — filter a single key from os.environ before merging. The env merge order (inherited_env → SDK defaults → options.env → SDK version) is preserved correctly, ensuring user overrides still work.

Other factors

The previously reported f-string brace escaping issue in the concurrent write tests has been resolved by a follow-up commit (b558fc1). The current test file has correct f-strings. Both new tests follow the same mock pattern used by existing env-related tests in the file. All 305 tests pass per the PR description.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward env var filtering with good test coverage.

Extended reasoning...

Overview

This PR makes a small, targeted change to SubprocessCLITransport.connect() in subprocess_cli.py: it filters the CLAUDECODE environment variable from os.environ before passing it to the child subprocess. Two new tests in test_transport.py verify that (1) CLAUDECODE is not inherited and (2) users can still explicitly set it via options.env.

Security risks

No security concerns. The change reduces unintended env var leakage into subprocesses. The filtering is narrowly scoped to a single known env var, and the override path via options.env is intentional and documented.

Level of scrutiny

This is a low-risk, mechanical fix. The dict comprehension is trivial to verify, the env merge order (inherited → defaults → user options → SDK version) is preserved, and the tests directly assert the expected behavior. The previous inline comment about f-string brace escaping has been resolved.

Other factors

The PR fixes a real issue (#573) where SDK subprocesses misbehave when launched from Claude Code hooks/plugins. The implementation follows the existing pattern in the codebase for env construction. Both new tests follow the same mock structure as the existing test_env_vars_passed_to_subprocess and test_caller_can_override_entrypoint tests.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, targeted env var filter with good test coverage for the core behavior.

Extended reasoning...

Overview

This PR makes a minimal 3-line production change in subprocess_cli.py to filter CLAUDECODE from the inherited environment when spawning CLI subprocesses. Two new tests are added in test_transport.py to verify the filtering and the options.env override path. The change fixes a real issue (#573) where SDK subprocesses incorrectly inherited CLAUDECODE=1 from parent Claude Code hooks/plugins.

Security risks

No security concerns. The change restricts environment variable inheritance (a defensive measure), and the filtered variable (CLAUDECODE) is a feature flag, not a credential or secret.

Level of scrutiny

Low scrutiny warranted. This is a straightforward environment variable filter using a dict comprehension — a common Python pattern with no ambiguity. The merge order (inherited_env -> defaults -> options.env -> SDK version) is correct and well-commented.

Other factors

The one nit found (test not fully exercising the filter+override interaction) is a valid test quality improvement but does not affect production correctness. The first test (test_claudecode_env_var_not_inherited) properly covers the critical filtering path by injecting CLAUDECODE into os.environ. The previous f-string bug report from my prior run was on pre-existing code not modified by this PR and is already resolved.

@qing-ant
Copy link
Copy Markdown
Contributor Author

E2E Test Results

Test script:

"""E2E test for PR #732: Filter CLAUDECODE env var from subprocess environment.

This test sets CLAUDECODE=1 in the parent process env, then uses
claude_agent_sdk.query() to send a simple prompt. Without the fix,
the CLI subprocess would inherit CLAUDECODE=1 and think it's running
inside Claude Code, causing it to malfunction. With the fix, the env
var is filtered out and the query succeeds normally.
"""

import asyncio
import os
import sys

# Set the env var BEFORE importing or using the SDK, simulating a scenario
# where the SDK is used from within a Claude Code extension or similar context.
os.environ["CLAUDECODE"] = "1"

import claude_agent_sdk  # noqa: E402
from claude_agent_sdk import ClaudeAgentOptions  # noqa: E402


async def main() -> None:
    print(f"CLAUDECODE in parent env: {os.environ.get('CLAUDECODE')!r}")
    print(f"SDK version: {claude_agent_sdk.__version__}")
    print()
    print("Sending query with max_turns=1...")
    print()

    messages = []
    try:
        async for msg in claude_agent_sdk.query(
            prompt="Reply with exactly: hello world",
            options=ClaudeAgentOptions(max_turns=1),
        ):
            messages.append(msg)
    except Exception as exc:
        print(f"FAIL: query raised {type(exc).__name__}: {exc}", file=sys.stderr)
        sys.exit(1)

    print(f"Num messages: {len(messages)}")
    print()

    found_hello_world = False
    for i, msg in enumerate(messages):
        msg_dict = {}
        for attr in ["type", "role", "content", "session_id", "message"]:
            val = getattr(msg, attr, None)
            if val is not None:
                if isinstance(val, list):
                    msg_dict[attr] = [str(v)[:100] for v in val]
                else:
                    msg_dict[attr] = str(val)[:200]
        print(f"  [{i}] {msg_dict}")

        content = getattr(msg, "content", None)
        if isinstance(content, str) and "hello world" in content.lower():
            found_hello_world = True
        elif isinstance(content, list):
            for block in content:
                text = getattr(block, "text", "")
                if "hello world" in text.lower():
                    found_hello_world = True

    print()
    if found_hello_world:
        print("PASS: Query succeeded with CLAUDECODE=1 in parent env.")
        print("The fix correctly filters CLAUDECODE from the subprocess environment.")
    else:
        all_text = " ".join(str(m) for m in messages)
        if "hello world" in all_text.lower():
            print("PASS: Query succeeded with CLAUDECODE=1 in parent env.")
        else:
            print("FAIL: Did not find expected 'hello world' in responses.", file=sys.stderr)
            sys.exit(1)


if __name__ == "__main__":
    asyncio.run(main())

Output:

CLAUDECODE in parent env: '1'
SDK version: 0.1.50

Sending query with max_turns=1...

Num messages: 5

  [0] {}
  [1] {'content': ['ThinkingBlock(...)']}
  [2] {'content': ["TextBlock(text='hello world')"]}
  [3] {'session_id': '98a0a6fc-f00a-42b8-8d1b-6672eada3a56'}
  [4] {'session_id': '98a0a6fc-f00a-42b8-8d1b-6672eada3a56'}

PASS: Query succeeded with CLAUDECODE=1 in parent env.
The fix correctly filters CLAUDECODE from the subprocess environment.
EXIT_CODE=0

Summary: With CLAUDECODE=1 set in the parent process environment, claude_agent_sdk.query() successfully completed a round-trip query and returned the expected assistant response. The fix correctly strips CLAUDECODE from the subprocess env so the CLI does not think it is running inside Claude Code. Unit tests (test_claudecode_env_var_not_inherited, test_claudecode_can_be_set_via_options_env) also pass.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — simple, correct env var filtering with appropriate tests.

Extended reasoning...

Overview

This PR modifies two files: subprocess_cli.py (3-line production change) and test_transport.py (two new test functions). The production change adds a dict comprehension to filter CLAUDECODE from os.environ before passing the environment to the CLI subprocess, preventing it from mistakenly thinking it is running inside a Claude Code parent process. Users can still explicitly set CLAUDECODE via options.env since that dict is spread after the filter.

Security risks

None. The change removes an environment variable from subprocess inheritance rather than adding one. No auth, crypto, or permissions code is affected.

Level of scrutiny

This is a low-risk, small bug fix. The production change is a single dict comprehension with clear semantics. The env merge ordering (inherited_env filtered first, then options.env spread on top) is correct and matches the existing pattern. E2E verification confirms the fix works in practice.

Other factors

My two previous inline comments are both marked resolved. The f-string brace escaping issue was a false positive (those lines are not modified by this PR). The patch.dict no-op in the override test is a minor test quality nit — the production code is correct regardless. The PR has passing E2E tests and all 305 unit tests pass.

@qing-ant qing-ant force-pushed the fix/filter-claudecode-env-v2 branch from 037826b to 1223bd9 Compare March 26, 2026 03:14
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — simple, well-scoped env var filter with test coverage and E2E verification.

Extended reasoning...

Overview

This PR makes a minimal change to subprocess_cli.py: filtering the CLAUDECODE env var from os.environ before passing it to the child subprocess, so SDK-spawned CLI processes don't mistakenly think they're running inside a Claude Code parent. Two new unit tests verify the filtering and the explicit override path via options.env.

Security risks

No security concerns. The change only removes one specific env var from subprocess inheritance. Users can still explicitly set it via options.env if needed.

Level of scrutiny

This is a low-risk, 3-line production code change with a clear motivation (fixes #573). The env merging logic is straightforward dict comprehension + spread. The follow-up commits addressed the f-string brace escaping issue I flagged earlier. E2E verification confirms the fix works end-to-end.

Other factors

Both prior inline comments from my earlier reviews are resolved. The test for options.env override still uses a no-op patch.dict (minor test quality nit, not a production issue), but the production code is correct and the primary filtering test is solid. All 305 unit tests pass, ruff clean.

@qing-ant qing-ant enabled auto-merge (squash) March 26, 2026 06:07
Copy link
Copy Markdown
Collaborator

@bogini bogini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamped 🦾

@qing-ant qing-ant merged commit 5839ff9 into main Mar 26, 2026
10 checks passed
@qing-ant qing-ant deleted the fix/filter-claudecode-env-v2 branch March 26, 2026 06:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Subprocess inherits CLAUDECODE=1 env var, preventing SDK usage from Claude Code hooks/plugins

2 participants