Skip to content

Permission gate: a command glued to zsh's &! inside a -c wrapper escapes the inner deny scan #4516

Description

@KuSh

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.

Activity

  1. rtk-wshm-sync-bot commented on Oct 11, 2026

    @rtk-wshm-sync-bot

    wshm · Automated triage by AI

    🔍 Automated Triage

    🐛 Category bug
    🟡 Priority medium
    🎯 Confidence 90%

    Summary

    Permission deny-scan misses zsh's glued &!command form inside zsh -c wrappers because the bash-shaped segmenter/grammar-stripper doesn't account for zsh's operator dialect; fix segmentation or stripping to peel glued !/{.

    This looks like a simple fix that could be auto-resolved. Repo maintainers can use /wshm fix to attempt it.

    📁 Relevant files
    • src/permissions/split_for_permissions.rs
    • src/permissions/strip_grammar_residue.rs

    Suggested Actions

    1. Make split_for_permissions treat a ! Shellism as a segment boundary even when glued to the next word
    2. Alternatively, extend strip_grammar_residue to peel a leading !/{ without requiring a space/tab separator
    3. Add regression tests for the three zsh &!/{...} glued forms from the issue table
    4. Cross-reference disown_pair: unreachable Arg arm, a redundant second call, and the &! rule applied to fish where both spellings agree #4517 (same &!/disown_pair area) and feat(rewrite): rewrite inner commands of a quoted fish -c wrapper #4126 (the check this gap was found in) when implementing

    Triaged automatically by wshm · This is an automated analysis, not a human review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions