What happens
With a deny rule in ~/.claude/settings.json:
{"permissions":{"deny":["Bash(rm:*)"],"allow":["Bash(*)"]}}
a command glued to zsh's &! operator inside a -c wrapper is not reached by the wrapper's inner deny scan, while the spaced spelling of the same thing is denied:
rtk rewrite "zsh -c 'git status &! rm -rf /tmp/example'" exit 2 (Deny)
rtk rewrite "zsh -c 'git status &!rm -rf /tmp/example'" exit 1 (no verdict)
rtk rewrite "zsh -c 'git status &!{ rm -rf /tmp/example; }'" exit 1 (no verdict)
zsh runs the hidden command in both spellings — &! is one operator there (background and disown), so what follows it is an ordinary command:
$ zsh -c 'echo a &!echo HIT'
HIT
a
Why only this shape
split_for_permissions breaks a segment on the & Shellism but not on the ! that is glued to it, so the matcher is handed the raw slice !rm -rf /tmp/example. strip_grammar_residue peels only a standalone grammar word — it requires rest.split_once([' ', '\t']) to yield exactly "!" — so !rm is never stripped and rm:* never matches.
It is zsh-specific. In bash and dash, !rm is a command name, not grammar, so the glued forms simply fail:
$ bash -c 'echo a &!echo HIT'
a
bash: line 1: !echo: command not found
$ zsh -c 'echo a; !echo HIT'
a
zsh:1: command not found: !echo
So the only spelling that both hides a command and executes it is zsh plus a glued &!.
Not a regression
develop denies none of these — it does not look inside a zsh -c wrapper at all — and the spaced sibling being denied is a protection #4126 adds. This is an incomplete new check, not a lost one. Measured across the three binaries under the same rules:
develop #4126
zsh -c 'git status &! rm -rf /tmp/example' defer DENY
zsh -c 'git status &!rm -rf /tmp/example' defer defer <- this issue
zsh -c 'git status; ! rm -rf /tmp/example' defer DENY
zsh -c 'git status; rm -rf /tmp/example' defer DENY
Fix direction
Either make split_for_permissions treat a ! Shellism as a segment boundary, or have the strip peel a leading !/{ even when it is glued to the next word. Peeling a glued ! can only ever "over-deny" a command literally named !rm, which does not exist. The deeper version is the one the #4126 body already names for fish's and: the shared bash-shaped segmenter cannot segment a shell whose operator table differs, and the permission path does not yet take the wrapper's dialect into account the way the rewrite path now does.
Same family as #4281 (deny and ask missing a command behind a prefix the host strips) but a different mechanism and a different fix site: that one is about prefix handling, this one is about operator segmentation inside a wrapper script.
Found
Reviewing #4126 (round 2). zsh 5.9.2, bash 5.3.9, dash, in debian:sid; rtk built from b969c3a10.
What happens
With a deny rule in
~/.claude/settings.json:{"permissions":{"deny":["Bash(rm:*)"],"allow":["Bash(*)"]}}a command glued to zsh's
&!operator inside a-cwrapper is not reached by the wrapper's inner deny scan, while the spaced spelling of the same thing is denied:zsh runs the hidden command in both spellings —
&!is one operator there (background and disown), so what follows it is an ordinary command:Why only this shape
split_for_permissionsbreaks a segment on the&Shellism but not on the!that is glued to it, so the matcher is handed the raw slice!rm -rf /tmp/example.strip_grammar_residuepeels only a standalone grammar word — it requiresrest.split_once([' ', '\t'])to yield exactly"!"— so!rmis never stripped andrm:*never matches.It is zsh-specific. In bash and dash,
!rmis a command name, not grammar, so the glued forms simply fail:So the only spelling that both hides a command and executes it is zsh plus a glued
&!.Not a regression
developdenies none of these — it does not look inside azsh -cwrapper at all — and the spaced sibling being denied is a protection #4126 adds. This is an incomplete new check, not a lost one. Measured across the three binaries under the same rules:Fix direction
Either make
split_for_permissionstreat a!Shellism as a segment boundary, or have the strip peel a leading!/{even when it is glued to the next word. Peeling a glued!can only ever "over-deny" a command literally named!rm, which does not exist. The deeper version is the one the #4126 body already names for fish'sand: the shared bash-shaped segmenter cannot segment a shell whose operator table differs, and the permission path does not yet take the wrapper's dialect into account the way the rewrite path now does.Same family as #4281 (deny and ask missing a command behind a prefix the host strips) but a different mechanism and a different fix site: that one is about prefix handling, this one is about operator segmentation inside a wrapper script.
Found
Reviewing #4126 (round 2). zsh 5.9.2, bash 5.3.9, dash, in
debian:sid; rtk built fromb969c3a10.