Skip to content

[BUG] Environment Variables Cleared When Using Pipe Operator in Bash Tool #8318

Closed

Description

@andre-c-andersen

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • I am using the latest version of Claude Code

What's Wrong?

When using the Bash tool to access a custom environment variable, say CLAUDE_AGENT_ID, with a pipe operator, the variable expands to only a newline character instead of its actual value "alpha". The environment variable exists and is visible with env | grep CLAUDE_AGENT_ID, but when piped to another command like wc -c, it loses its value.

What Should Happen?

The CLAUDE_AGENT_ID environment variable should consistently expand to its actual value "alpha" when used in any bash command, including when piped to other commands.

Error Messages/Logs

# Variable exists in environment
$ env | grep CLAUDE_AGENT_ID
CLAUDE_AGENT_ID=alpha

# But expands to only newline when piped
$ echo "$CLAUDE_AGENT_ID" | hexdump -C
00000000  0a                                                |.|
00000001

# Character count shows 1 instead of expected 6
$ echo $CLAUDE_AGENT_ID | wc -c
1

Steps to Reproduce

Please provide clear, numbered steps that anyone can follow to reproduce the issue. Important: Include any necessary code, file contents, or context needed to reproduce the bug. If the issue involves specific files or code, please create a minimal example.

  1. In Claude Code, use the Bash tool to echo the CLAUDE_AGENT_ID:

    echo $CLAUDE_AGENT_ID

    Result: Shows "alpha" (appears correct)

  2. Use the Bash tool to count characters with a pipe:

    echo $CLAUDE_AGENT_ID | wc -c

    Result: Returns 1 (incorrect - should be 6)

  3. Verify the variable exists:

    env | grep CLAUDE_AGENT_ID

    Result: Shows CLAUDE_AGENT_ID=alpha (correct)

  4. Check actual bytes being output:

    echo "$CLAUDE_AGENT_ID" | hexdump -C

    Result: Shows only 0a (newline character)

  5. Run in a new bash subprocess:

    bash -c 'echo "$CLAUDE_AGENT_ID" | wc -c'

    Result: Returns 6 (correct - works in subprocess)

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

No response

Claude Code Version

1.0.128 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

WSL (Windows Subsystem for Linux)

Additional Information

Anything else that might help us understand the issue?

  • The bug only occurs when using pipes with the environment variable
  • Direct echo without pipes appears to work (shows "alpha")
  • The variable works correctly when executed in a new bash subprocess using bash -c
  • This appears to be a shell state issue where the variable exists in the environment but isn't properly accessible in the current shell context
  • Use case where discovered: I observed this issue when trying to inject basic authentication credentials for curl commands using environment variables (e.g., curl -u "$USERNAME:$PASSWORD" https://api.example.com). The authentication would fail because the variables weren't expanding correctly when piped or used in command substitution.
  • Date observed: 2025-09-28

Activity

  1. stockmaj commented on Nov 21, 2025

    @stockmaj

    Additional Context: Affects More Than Just Pipes

    I can confirm this bug and wanted to add that the issue extends beyond just pipe operators - environment variables are also empty when passed to subprocess commands like curl.

    Real-World Impact Example

    When trying to use an API token in a curl Authorization header:

    curl -k https://api.example.com/endpoint \
      -H "Authorization: Bearer $MY_API_TOKEN" \
      -d "data=value"

    Result: Authentication fails because curl receives an empty token, even though:

    • ${#MY_API_TOKEN} correctly returns 501 (the token length)
    • env | grep MY_API_TOKEN shows the full token value

    Inconsistency Details

    # These work:
    $ echo ${#MY_API_TOKEN}
    501
    
    $ env | grep MY_API_TOKEN
    MY_API_TOKEN=<full token visible here>
    
    # These fail (return 0 or empty):
    $ printf '%s' "$MY_API_TOKEN" | wc -c
    0
    
    $ curl -H "Authorization: Bearer $MY_API_TOKEN" https://api.example.com
    # Returns: 401 Unauthorized (token is empty)

    Confirmed Workaround

    Writing commands to a script file and executing them works correctly:

    # Write to script
    cat > test.sh << 'EOF'
    #!/bin/bash
    curl -H "Authorization: Bearer $MY_API_TOKEN" https://api.example.com
    EOF
    
    # Execute script
    bash test.sh
    # Works correctly - token is properly expanded

    This suggests the issue is specifically with how the Bash tool passes environment variables to inline commands vs. script files.

  2. bukzor commented on Nov 24, 2025

    @bukzor

    Verified: (the CLAUDE_AGENT_ID doesn't seem to exist anymore, but the same happens with CLAUDE_CODE_ENTRYPOINT)

    ● Bash(env | grep CLAUDE_CODE_ENTRYPOINT)
      ⎿  CLAUDE_CODE_ENTRYPOINT=cli
    
    ● Bash(echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C)
      ⎿  00000000  0a                                                |.|
         00000001
    
    ● Bash(echo $CLAUDE_CODE_ENTRYPOINT | wc -c)
      ⎿  1
    
    ● The bug is confirmed. CLAUDE_CODE_ENTRYPOINT exists in the environment (shown by env | grep), but when accessed via
      echo "$CLAUDE_CODE_ENTRYPOINT", it returns empty (only a newline). The hexdump shows only 0a (newline) and wc -c shows
       1 character.
    
      This indicates environment variables are visible to env but not accessible to shell variable expansion in the Bash
      tool's subprocess.
    
  3. bukzor commented on Nov 24, 2025

    @bukzor

    Workaround: advise Claude to always use bash <<'BASH' .... BASH when using the Bash tool.

    ● Now I'll try the commands again using the heredoc format to work around the escaping bugs:
    
    ● Bash(bash <<'BASH'
          env | grep CLAUDE_CODE_ENTRYPOINT…)
      ⎿  CLAUDE_CODE_ENTRYPOINT=cli
    
    ● Bash(bash <<'BASH'
          echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C…)
      ⎿  00000000  63 6c 69 0a                                       |cli.|
         00000004
    
    ● Bash(bash <<'BASH'
          echo $CLAUDE_CODE_ENTRYPOINT | wc -c…)
      ⎿  4
    
    ● Perfect! The heredoc workaround fixes the issue completely:
    
      Without heredoc:
      - hexdump: Shows only 0a (newline)
      - wc -c: Shows 1 character
    
      With heredoc:
      - hexdump: Shows 63 6c 69 0a ("cli\n")
      - wc -c: Shows 4 characters
    
      The bug is confirmed: environment variables aren't properly expanded without using the heredoc format. Your workaround
       in ~/.claude/must-read-before.d/using-claude-code-tool/Bash.md successfully addresses this escaping bug.
    
  4. smconner commented on Dec 9, 2025

    @smconner

    PreToolUse Hook Workaround for Bash Preprocessing Bugs (v7)

    A hook that fixes all known bash preprocessing bugs in Claude Code by wrapping commands in bash -c '...'.

    Zero token overhead — hooks run externally before command execution.


    v7 Update: "Wrap Everything" Approach

    Credit: @AdamScherlis identified a gap in v6's pattern-matching. Commands like echo $CLAUDE_CODE_ENTRYPOINT | wc -c weren't being wrapped because they don't match any detection patterns (no $(), no newlines, no loop keywords) — yet they still trigger the preprocessing bug.

    Why Pattern-Matching Failed

    v1-v6 tried to detect "problematic" commands by looking for:

    • $(...) command substitution
    • Newlines in commands
    • Loops with pipes (for ... | ...)

    This was ~30 lines of regex that inevitably had blind spots.

    The Fix: Wrap Everything

    AdamScherlis's suggestion: just wrap ALL commands unconditionally.

    Concerns we evaluated:

    Concern Finding
    Performance overhead? Negligible — bash -c adds microseconds
    Breaks existing commands? Tested 10,749 real Claude commands — no issues
    Escape sequence differences? /bin/sh vs bash differ on some escapes, but Claude uses echo -e or $'...' when it needs escape interpretation — never relies on raw \t in double quotes

    Result: Simpler code, more robust, future-proof against unknown bugs.


    GitHub Issues Fixed

    Issue Problem
    #11225 $(...) command substitution mangled
    #11182 Multi-line commands have newlines stripped
    #8318 Loop variables silently cleared with pipes
    #8318 Environment variables cleared with pipes (AdamScherlis)
    #10014 For-loop variable expansion issues

    Installation

    Step 1: Save the hook

    mkdir -p ~/.claude/hooks
    cat > ~/.claude/hooks/fix-bash-substitution.py << 'EOF'
    #!/usr/bin/env python3
    """
    Claude Code Bash Hook - Fix preprocessing bugs (v7)
    
    Wraps ALL bash commands in `bash -c '...'` to bypass preprocessing bugs.
    Credit: @AdamScherlis for the "wrap everything" approach.
    
    Version: 7 (2025-12-25)
    """
    import json
    import re
    import sys
    
    ESCAPE_MARKERS = ["# no-wrap", "# bypass-hook", "# skip-hook"]
    
    
    def has_escape_marker(command: str) -> bool:
        return any(marker in command for marker in ESCAPE_MARKERS)
    
    
    def has_control_structures(command: str) -> bool:
        """Detect bash control structures (if/for/while/case)."""
        patterns = [
            r'\bif\b.*\bthen\b', r'\bfor\b.*\bdo\b', r'\bwhile\b.*\bdo\b',
            r'\buntil\b.*\bdo\b', r'\bcase\b.*\bin\b', r'\bfunction\b',
        ]
        return any(re.search(p, command, re.MULTILINE) for p in patterns)
    
    
    def fix_continuations(command: str) -> str:
        """Add backslash continuations for multi-line commands (quote-aware)."""
        if '\n' not in command or has_control_structures(command):
            return command
    
        result, in_sq, in_dq, i = [], False, False, 0
        while i < len(command):
            c = command[i]
            if c == "'" and not in_dq and (i == 0 or command[i-1] != '\\'):
                in_sq = not in_sq
            elif c == '"' and not in_sq and (i == 0 or command[i-1] != '\\'):
                in_dq = not in_dq
            if c == '\n' and not (in_sq or in_dq):
                if i + 1 < len(command) and command[i+1] in ' \t':
                    if i == 0 or command[i-1] != '\\':
                        result.append(' \\')
            result.append(c)
            i += 1
        return ''.join(result)
    
    
    def needs_wrapping(command: str) -> bool:
        """v7: Wrap EVERYTHING except already-wrapped and escape-marked commands."""
        stripped = command.strip()
        if stripped.startswith("bash -c ") or stripped.startswith("bash -c'"):
            return False  # Already wrapped
        if has_escape_marker(command):
            return False  # User opt-out
        return True  # Wrap everything else
    
    
    def main():
        try:
            data = json.load(sys.stdin)
        except json.JSONDecodeError:
            sys.exit(1)
    
        if data.get("tool_name") != "Bash":
            sys.exit(0)
    
        command = data.get("tool_input", {}).get("command", "")
        if not command or not needs_wrapping(command):
            sys.exit(0)
    
        # Fix continuations (skip for heredocs)
        fixed = command if '<<' in command else fix_continuations(command)
    
        # Escape single quotes and wrap
        escaped = fixed.replace("'", "'\\''")
        wrapped = f"bash -c '{escaped}'"
    
        print(json.dumps({
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "allow",
                "updatedInput": {"command": wrapped}
            }
        }))
    
    
    if __name__ == "__main__":
        main()
    EOF

    Step 2: Make executable

    chmod +x ~/.claude/hooks/fix-bash-substitution.py

    Step 3: Configure Claude Code

    Run /hooks in Claude Code, then:

    1. Select Add hook
    2. Select PreToolUse
    3. Select Bash as the tool matcher
    4. Enter path: ~/.claude/hooks/fix-bash-substitution.py

    Step 4: Restart session

    Start a new Claude Code session for the hook to take effect.


    Verifying It Works

    # All of these should now work correctly:
    
    # Command substitution
    echo "Today is $(date +%Y-%m-%d)"
    
    # Multi-line
    curl https://api.example.com \
        -H "Accept: application/json"
    
    # Loop with pipe
    for i in 1 2 3; do echo $i | cat; done
    
    # Environment variable with pipe (the v7 fix)
    MY_VAR=hello; echo $MY_VAR | wc -c

    Escape Markers

    To skip wrapping for a specific command, add a comment:

    echo $(date) # no-wrap
    echo $(date) # bypass-hook
    echo $(date) # skip-hook

    Changelog

    Version Date Changes
    v7 2025-12-25 "Wrap everything" — @AdamScherlis's approach. Simpler, more robust.
    v6 2025-12-11 Control structure detection for nested if/for/while
    v5 2025-12-10 Skip continuation-fixing for control structures
    v4 2025-12-10 Heredoc detection
    v3 2025-12-10 Quote-aware continuation fixing
    v2 2025-12-09 Loop-with-pipe detection
    v1 2025-12-09 Initial release
  5. AdamScherlis commented on Dec 25, 2025

    @AdamScherlis

    The hook above does not fix this issue, because it doesn't include the "env variable + pipe" failure mode in needs_wrapping().

    e.g. echo $CLAUDE_CODE_ENTRYPOINT | wc -c will not get wrapped.

    Skipping the needs_wrapping check and wrapping every bash call seems to work fine; are there downsides to this approach?

    def needs_wrapping(command: str) -> bool:
        """Always wrap commands to bypass preprocessing bugs."""
        stripped = command.strip()
    
        # Already wrapped - skip
        if stripped.startswith("bash -c ") or stripped.startswith("bash -c'"):
            return False
    
        return True
  6. smconner commented on Dec 25, 2025

    @smconner

    @AdamScherlis You're absolutely right — updated the hook to v7 with your "wrap everything" approach. Thank you for the catch and the elegant solution.

    The Problem You Identified

    echo $CLAUDE_CODE_ENTRYPOINT | wc -c wasn't getting wrapped because v6 looked for specific patterns:

    • $(...) — not present
    • Newlines — not present
    • Loop keywords with pipes — not present

    Yet it still triggers the preprocessing bug. Pattern-matching will always have blind spots.

    Evaluating "Wrap Everything"

    Before adopting your approach, I tested potential downsides:

    Concern Investigation Result
    Performance? bash -c overhead Microseconds — negligible
    Breaks commands? Tested 10,749 real Claude commands from history Zero issues
    Escape sequences? /bin/sh interprets \t differently than bash -c Claude uses echo -e or $'...' for escapes — never relies on the affected behavior

    The only "failures" in the test suite were tests with wrong expectations (relying on /bin/sh quirks that differ from bash).

    The Fix

    def needs_wrapping(command: str) -> bool:
        """v7: Wrap EVERYTHING."""
        stripped = command.strip()
        if stripped.startswith("bash -c "):
            return False  # Already wrapped
        if has_escape_marker(command):
            return False  # User opt-out
        return True  # Wrap everything else

    ~30 lines of pattern detection → 5 lines. Simpler, more robust, future-proof.

    Credit added to the hook source and main comment. Thanks again!

  7. github-actions commented on Jan 24, 2026

    @github-actions

    This issue has been inactive for 30 days. If the issue is still occurring, please comment to let us know. Otherwise, this issue will be automatically closed in 30 days for housekeeping purposes.

  8. Butanium commented on Feb 1, 2026

    @Butanium

    this is still happening

  9. mhelvens commented on Feb 27, 2026

    @mhelvens

    I just reported #29298, which does appear to be a duplicate of this bug. However, this bug report seems like it's being ignored a bit, maybe? So I don't want to close mine as a duplicate yet. It contains a bunch of test cases which may be helpful.

    Also, I get the feeling this bug was fixed at some earlier versions and then reintroduced? Either that or I'm delusional.

  10. in-op commented on Mar 2, 2026

    @in-op

    Still an issue on 2.1.63. On mac

  11. brokensandals commented on Mar 8, 2026

    @brokensandals

    Just ran into this on macos 26.3, claude code 2.1.71, zsh 5.9 (arm64-apple-darwin25.0). It was very time-consuming for me to troubleshoot why the commands that worked in my terminal didn't work for claude; hope this gets fixed at some point!

    A simple workaround that seems to be working for me is to tell claude to use $(printenv VAR) instead of $VAR.

  12. hjtran commented on Mar 11, 2026

    @hjtran

    There are workarounds for this - but we're spending quite a bit of time implementing them and deploying them widely in our org. It'd really save us a lot of time and pain if a proper fix could be implemented

  13. shawnz-swiftly commented on Mar 22, 2026

    @shawnz-swiftly

    This appears to have been fixed within the last couple of weeks

  14. Butanium commented on Mar 22, 2026

    @Butanium

    Indeed!

    windows:

    ● Bash(export MY_TEST_VAR="hello_world" && echo $MY_TEST_VAR | cat)
      ⎿  hello_world
    
    ● Bash(export FOO="bar123" && echo "$FOO" | grep bar)
      ⎿  bar123
    
    ● Bash(export MULTI_VAR="test_value" && echo "$MULTI_VAR" | tr '_' '-' | cat)
      ⎿  test-value
    
    ● Bash(export A="one" B="two" && echo "$A $B" | wc -w)
      ⎿  2
    
    ● All working fine — env vars survive pipes without issue. Looks like the bug is indeed fixed (at least on Windows/Git Bash).
    

    linux

    
    ● Bash(echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C)
      ⎿  00000000  63 6c 69 0a                                       |cli.|
         00000004
    
    ● Bash(env | grep CLAUDE_CODE_ENTRYPOINT)
      ⎿  CLAUDE_CODE_ENTRYPOINT=cli
    
    ● Bash(claude --version 2>/dev/null || echo "version unknown")
      ⎿  2.1.79 (Claude Code)
    
    ● Confirmed fixed on version 2.1.79:
    
      - CLAUDE_CODE_ENTRYPOINT=cli exists in env
      - echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C → 63 6c 69 0a = cli\n (correct!)
      - echo $CLAUDE_CODE_ENTRYPOINT | wc -c → 4 (correct — 3 chars + newline)
    
      Previously this would show only 0a (empty newline) through the pipe. The bug from issue #8318 appears to be genuinely fixed.
      shawnz-swiftly's comment checks out.
    
  15. claude commented on Apr 17, 2026

    @claude
    Contributor

    This issue was fixed as of version 2.1.79.

  16. github-actions commented on Apr 25, 2026

    @github-actions

    This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.

  17. locked as resolved and limited conversation to collaborators on Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:toolsbugSomething isn't workinghas reproHas detailed reproduction stepsplatform:linuxIssue specifically occurs on LinuxstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions