Sandboxed Bash commands get a spurious backslash before `!` (shell-quote re-escapes the eval wrapper)
What happens
A Bash tool command that runs clean unsandboxed gets silently corrupted when it runs under the sandbox: a backslash is inserted before every ! in the command, and that backslash survives all the way to the spawned program. A single-quoted argument containing idx[1]!==undefined reaches the child process as idx[1]\!==undefined.
This bit me through a small CLI that evals a --code '<js>' argument. Node received idx[1]\!==undefined and threw SyntaxError: Invalid or unexpected token at [eval]:1, even though the command stored in the session transcript was clean (no backslash). The same command bytes ran fine in a different session a few minutes later, which is what makes this one so confusing to report.
Repro
In a session with the sandbox armed (Linux, sandbox.enabled: true, a working bwrap, no dangerouslyDisableSandbox), run a Bash command with a quoted !:
echo 'a!b'
Actual: prints a\!b. Expected: a!b. A double-quoted bang corrupts the same way (echo "a!b" also prints a\!b). Or, matching my original failure:
node -e 'console.log(1!==2)'
Actual: SyntaxError: Invalid or unexpected token. Expected: true. The same commands run clean in a session that isn't using the sandbox route. The bang has to be quoted (single or double quotes, or a heredoc body) to reproduce: an unquoted echo hi! comes out clean since the eval's own bash strips the backslash outside quotes, and an unquoted reserved-word ! just fails with command not found.
Why it looks session-conditional
The corruption only happens on the sandbox execution path, so whether a given session reproduces it just comes down to whether the sandbox is actually armed for that command. Note that this is independent of the permission allow/ask/deny outcome and of autoAllowBashIfSandboxed. In my case the failing session had the sandbox armed and the bang command carried no bypass flag, so it took the sandbox route; a later session ran the non-sandbox route (the sandbox had disarmed for it) and the identical command was clean. As such, replaying a stored command to "confirm" can mislead, since it'll often land on the clean route.
Root cause
The non-sandbox path quotes the command correctly. It builds a wrapper along the lines of:
source <snapshot>.sh ... && eval '<command>' < /dev/null && pwd -P >| <cwd-file>
embedding the command with the POSIX close-quote/reopen single-quote idiom ('"'"'), which is bang-safe.
The sandbox path then takes that whole, already-quoted wrapper string and runs it through the bundled shell-quote quote() a second time. Since the wrapper always contains a single quote plus whitespace (from its own eval '...'), shell-quote takes its double-quote-wrapping branch, whose escape set is `["\\$!]`. That inserts a backslash before every !, and since [ and ] aren't in the set, the signature is bang-only. The outer bash -c then preserves \! inside double quotes (with histexpand off, bash strips \$, \ `, \", and \\, but keeps \!`), so the spurious backslash reaches the eval'd command and whatever it spawns.
The ! escaping exists to defend against history expansion, but the consuming shell is non-interactive (set -H is off there), so the escape is both unnecessary and never undone, and it just leaks a literal backslash. The deeper problem is that the sandbox path re-quotes a string that was already correctly single-quoted, using a different quoter than the non-sandbox path, so the two paths disagree on the bytes that reach the program. Any sandboxed command with a quoted ! is affected, whether the user wrote single or double quotes, since the escaping keys off the wrapper rather than the raw command. (An unquoted ! happens to survive, since the eval's inner bash strips the backslash outside quotes.)
Suggested fix
Don't double-quote-wrap an already-quoted wrapper, or drop ! from the escape set on the non-interactive path (histexpand is off there). Either one makes the sandbox and non-sandbox paths agree on the bytes.
Environment
Claude Code 2.1.173, Linux, bash, bubblewrap 0.11.2.
---
Disclosure: Claude Fable 5 was used to file this issue.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗