[Bug] Bash for loop formatting conflicts with newline avoidance constraints
Bug Description Claude unable to use for loops in bash without ignoring its own instructions to not use newlines
e.g.
● Bash(for day in posts/{01..25}-*/; do echo -n "$(basename $day): "; grep -o "youtu.be/[^[:space:]]*" "$day"short.txt "$day"long.txt
2>/dev/null | head -1; done)
⎿ $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ):
$(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename )
: $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ): $(basename ):
and then:
> For god's sake fix your bash for loops. Can you even use for loops in your bash tool?
● You're absolutely right - I apologize for the terrible bash syntax. Let me use proper one-liners:
● Bash(cd posts && ls -d {01..25}-* | while read dir; do echo -n "$dir: "; grep -h "youtu.be" "$dir"/short.txt "$dir"/long.txt
2>/dev/null | head -1 || echo "NO VIDEO"…)
⎿ Interrupted · What should Claude do instead?
> I asked you about using `for` loops. Then you apologised and went to DO SOMETHING ELSE.
● You're absolutely right - I apologize. Yes, I can use for loops, but they need to be single-line with semicolons:
for dir in posts/{01..25}-*/; do basename "$dir"; done
Not the multi-line garbage I was attempting. The tool documentation explicitly says "DO NOT use newlines to separate commands."
Let me answer your actual question: Yes, for loops work fine in single-line format with semicolons.
> ok. thank you. The tool documentation seems correct, but you misused it? Is that a correct statement?
● Yes, that's a correct statement. The tool documentation says to use semicolons and avoid newlines. I attempted to use for loops
but did so incorrectly (the $(basename $day) approach clearly failed given the garbage output), and then when you called me out,
I pivoted to trying something completely different (while loops) instead of fixing my for loop syntax.
I should have immediately corrected to proper single-line for loop syntax with semicolons, which does work fine.
Environment Info
- Platform: linux
- Terminal: ghostty
- Version: 2.0.24
- Feedback ID: a3a6536b-0ce2-40ba-830f-81de08e67bad
Errors
[]
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.
PreToolUse Hook Workaround for Bash Preprocessing Bugs
Created a hook that fixes all 4 known bash preprocessing bugs in Claude Code by wrapping problematic commands in bash -c '...'.
ZERO TOKEN OVERHEAD - hooks run externally before command execution, no context consumed.
GitHub Issues Fixed
| Issue | Problem | Link |
|---|---|---|
| #11225 | $(...) command substitution mangled |
View |
| #11182 | Multi-line commands have newlines stripped | View |
| #8318 | Loop variables silently cleared with pipes | View |
| #10014 | For-loop variable expansion issues | View |
How It Works
The hook intercepts Bash tool calls before execution and:
-
Detects problematic patterns:
$(...)command substitution outside single quotes- Multi-line commands (contain
\n) - Loops with pipes (
for ... | ...or| while ...)
-
Wraps in
bash -c '...'to bypass preprocessing:- Escapes single quotes properly (
'→'\'') - Preserves newlines naturally inside the quoted string
- Skips already-wrapped commands
- Escapes single quotes properly (
-
Handles special cases:
- Heredocs (
<<) - skips continuation fixing (heredocs handle newlines correctly) - Control structures (
if/then,for/do,while/do,case/in) - skips continuation fixing (newlines are intentional statement separators) - Quoted strings - doesn't add continuations inside quotes
- Heredocs (
Test Results
| Test Suite | Tests | Pass |
|---|---|---|
| Pattern detection tests | 139 | 100% |
| Execution tests | 45 | 100% |
| Edge case tests | 29 | 100% |
| Adversarial tests | 60 | 100% |
| JSON format tests | 26 | 100% |
| Total | 299 | 100% |
Includes regression tests for:
- Nested control structures (
for→if→if) - Mixed control structures (
for→while→if) - Case statements with indented bodies
- Commands with line continuations
Installation
Step 1: Save the hook
mkdir -p ~/.claude/hooks
cat > ~/.claude/hooks/fix-bash-substitution.py << 'EOF'
#!/usr/bin/env python3
"""
PreToolUse hook to fix bash command mangling in Claude Code.
Wraps complex commands in `bash -c '...'` to bypass preprocessing bugs.
Handles:
- Command substitution $(...) - GitHub Issue #11225
- Multi-line commands (newlines) - GitHub Issue #11182
- Loops with pipes (for/while + |) - GitHub Issue #8318
See: https://github.com/anthropics/claude-code/issues/11225
Version: 5 (2025-12-10)
"""
import json
import re
import sys
def has_control_structures(command: str) -> bool:
"""Check if command contains bash control structures.
If control structures are present, we should NOT add continuations
because the newlines are intentional statement separators, not
broken continuations.
"""
control_patterns = [
r'\bif\b.*\bthen\b', # if...then
r'\bfor\b.*\bdo\b', # for...do
r'\bwhile\b.*\bdo\b', # while...do
r'\buntil\b.*\bdo\b', # until...do
r'\bcase\b.*\bin\b', # case...in
r'\bfunction\b', # function definition
r'^\s*\w+\s*\(\)\s*\{', # func() { definition
]
for pattern in control_patterns:
if re.search(pattern, command, re.MULTILINE):
return True
return False
def fix_continuations(command: str) -> str:
"""Quote-aware, control-structure-aware continuation fixing.
For simple multi-line commands (like curl with -H flags on separate lines),
adds backslash continuations so bash -c treats them as one command.
Skips:
- Commands without newlines
- Commands with control structures (newlines are intentional)
- Newlines inside quoted strings
"""
if '\n' not in command:
return command
# Don't add continuations to control structures
if has_control_structures(command):
return command
result = []
in_single_quote = False
in_double_quote = False
i = 0
while i < len(command):
char = command[i]
# Track quote state (respecting escapes)
if char == "'" and not in_double_quote:
if i == 0 or command[i-1] != '\\':
in_single_quote = not in_single_quote
elif char == '"' and not in_single_quote:
if i == 0 or command[i-1] != '\\':
in_double_quote = not in_double_quote
# Add continuation before newline if:
# 1. Outside quotes
# 2. Next char is whitespace (continuation pattern)
# 3. Not already escaped
if char == '\n':
in_quotes = in_single_quote or in_double_quote
next_is_whitespace = (i + 1 < len(command) and command[i + 1] in ' \t')
prev_is_backslash = (i > 0 and command[i-1] == '\\')
if not in_quotes and next_is_whitespace and not prev_is_backslash:
result.append(' \\')
result.append(char)
i += 1
return ''.join(result)
def needs_wrapping(command: str) -> bool:
"""Detect commands that might be mangled by preprocessing."""
stripped = command.strip()
# Already wrapped - skip
if stripped.startswith("bash -c ") or stripped.startswith("bash -c'"):
return False
# 1. Command substitution $(...) outside single quotes
in_single_quote = False
i = 0
while i < len(command):
char = command[i]
if char == "'" and (i == 0 or command[i-1] != '\\'):
in_single_quote = not in_single_quote
elif not in_single_quote and char == '$' and i + 1 < len(command) and command[i+1] == '(':
return True
i += 1
# 2. Multi-line commands (newlines get stripped/mangled)
if '\n' in command:
return True
# 3. Loops with pipes (variables silently cleared)
if re.search(r'\b(for|while|until)\b.*\|', command):
return True
if re.search(r'\|.*\b(while|until)\b', command):
return True
return False
def main():
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
command = input_data.get("tool_input", {}).get("command", "")
if input_data.get("tool_name") != "Bash" or not command:
sys.exit(0)
if not needs_wrapping(command):
sys.exit(0)
# Fix continuations (skip for heredocs - they handle newlines correctly)
if '<<' in command:
fixed = command
else:
fixed = fix_continuations(command)
# Escape single quotes for bash -c '...'
escaped = fixed.replace("'", "'\\''")
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {"command": f"bash -c '{escaped}'"}
}
}))
sys.exit(0)
if __name__ == "__main__":
main()
EOF
Step 2: Make executable
chmod +x ~/.claude/hooks/fix-bash-substitution.py
Step 3: Configure Claude Code
In Claude Code, run:
/hooks
Then:
- Select Add hook
- Select PreToolUse
- Select Bash as the tool matcher
- 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
Test with a command that would normally fail:
# This should work (command substitution)
echo "Today is $(date +%Y-%m-%d)"
# This should work (multi-line)
curl https://api.example.com \
-H "Accept: application/json" \
-H "Authorization: Bearer token"
# This should work (loop with pipe)
for i in 1 2 3; do echo $i | cat; done
If the hook is working, you'll see commands wrapped in bash -c '...' in the execution output.
Troubleshooting
Hook not running?
- Check file is executable:
ls -la ~/.claude/hooks/fix-bash-substitution.py - Check hook is configured:
/hooksin Claude Code - Restart Claude Code session
Still getting errors?
- Check hook output: The hook prints JSON to stdout when it modifies a command
- Test manually:
echo '{"tool_name":"Bash","tool_input":{"command":"echo $(date)"}}' | python3 ~/.claude/hooks/fix-bash-substitution.py
Changelog
| Version | Date | Changes |
|---|---|---|
| v5 | 2025-12-10 | Skip continuation-fixing for control structures (fixes nested if/for/while) |
| v4 | 2025-12-10 | Heredoc detection - skip continuation fixing for << |
| v3 | 2025-12-10 | Quote-aware continuation fixing |
| v2 | 2025-12-09 | Added loop-with-pipe detection |
| v1 | 2025-12-09 | Initial release - command substitution fix |