Bash tool: shell-quote escapes ! to \! in non-interactive shells, corrupting command data
Bug Description
The Bash tool's bundled shell-quote library escapes ! to \! when the command string triggers the double-quote wrapping path (branch 2). Since claude -p and the Bash tool run commands via bash -c (non-interactive shell), history expansion is disabled and the backslash is treated as a literal character, corrupting data sent to downstream tools.
Reproduction
Start a simple HTTP server that logs raw request bodies:
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_POST(self):
l = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(l)
print(f'RECEIVED: {repr(body)}', flush=True)
self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
def log_message(self, *a): pass
HTTPServer(('0.0.0.0', 9999), H).serve_forever()
Then via claude -p:
Run this exact command:
RESULT=$(curl -s -X POST http://127.0.0.1:9999 -d "flag=BCCTF{test!}" -o /dev/null -w '%{redirect_url}') && echo "$RESULT"
Expected: Server receives flag=BCCTF{test!}
Actual: Server receives flag=BCCTF{test\!}
The \! in the received data is a literal backslash + exclamation mark. The data is corrupted.
When it triggers
The bug hits shell-quote's branch 2 — when the full command string contains both a single quote AND whitespace/double-quote:
| Command | Branch | Result | Correct? |
|---------|--------|--------|----------|
| echo "hello!" | 1 (single-quote wrap) | 'echo "hello!"' | ✅ Yes |
| curl -d "flag=test!" -w '%{url}' | 2 (double-quote wrap) | "curl -d \"flag=test\!\" -w '%{url}'" | ❌ No → \! |
| grep -r 'TODO!' src/ | 2 (double-quote wrap) | "grep -r 'TODO\!' src/" | ❌ No → \! |
| git commit -m 'fix!' | 2 (double-quote wrap) | "git commit -m 'fix\!'" | ❌ No → \! |
| jq 'select(. != 2)' | 2 (double-quote wrap) | "jq 'select(. \!= 2)'" | ❌ No → \!= |
The trigger is mixed quoting (' and " or whitespace in the same command) combined with !.
Root cause
In the bundled shell-quote library:
// Branch 2: command has both ' and whitespace/" → wraps in "..."
if (/["'\s]/.test(q))
return '"' + q.replace(/(["\\$`!])/g, "\\$1") + '"';
// ^ escapes ! to \!
This escaping is intended for interactive bash where ! triggers history expansion inside "...". But Claude Code runs commands via bash -c (non-interactive shell) where histexpand is off by default. The \! doesn't get de-escaped and the backslash stays literal.
Impact
Any command with mixed quoting and ! is affected:
- curl/wget with flags containing
!(common in CTF challenges) - jq filters with
!= - awk expressions with
!~or!= - grep patterns containing
! - git commit messages with
! - SSH/sshpass commands with passwords containing
! - Any tool receiving
!as part of data or syntax
This is particularly impactful for claude -p (SDK/headless mode) where agents submit data containing special characters.
Environment
- Claude Code 2.1.78 installed via
npm install -g @anthropic-ai/claude-code - Node.js v22.22.0
- Bash 5.3.3 (Kali Linux container)
- NOT reproducible with the SEA binary (installed via
claudeCLI installer) on Node v20.20.0 — the SEA binary appears to handle this differently
Suggested fix
Remove ! from the branch 2 escape pattern:
// Before:
q.replace(/(["\\$`!])/g, "\\$1")
// After:
q.replace(/(["\\$`])/g, "\\$1")
This is safe because bash -c runs non-interactive shells where ! has no special meaning.
Workaround
Avoid single quotes in commands that contain !. If the command has no single quotes, shell-quote uses branch 1 (single-quote wrapping) which preserves !:
# Broken (mixed quotes → branch 2):
curl -d "flag=test!" -w '%{redirect_url}'
# Works (no single quotes → branch 1):
curl -d "flag=test!" -w "%{redirect_url}"
When single quotes can't be avoided, write data to a file first:
printf 'flag=%s' 'test!' > /tmp/data.txt
curl -d @/tmp/data.txt -w "%{redirect_url}"
Related issues
- #2941 (original report, closed due to inactivity)
- #10335 (duplicate, closed)
- #23740 (detailed root cause analysis, closed as duplicate)
- #24979 (awk commands affected, closed as duplicate)
All previous issues were closed automatically by the inactivity bot despite active user comments. The bug remains unfixed.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗