Skip to content

feat(rewrite): rewrite inner commands of a quoted fish -c wrapper - #4126

Merged
KuSh merged 4 commits into
rtk-ai:developfrom
pashifika:feat/fish-c-wrapper
Oct 11, 2026
Merged

KuSh merged 4 commits into
rtk-ai:developfrom
pashifika:feat/fish-c-wrapper

Conversation

@pashifika

Copy link
Copy Markdown
Contributor

Summary

PR B of the three-way split of #3911, per @KuSh's round-1 review — the part he asked to keep: "rewrite the inner commands of a quoted fish -c '…' wrapper, deferring on fish control flow and bare (…) inside that wrapper's script".

Two commits:

  1. fix(rewrite): rewrite inner commands of quoted sh/bash/zsh -c wrappers #3828's commit, rebased onto current develop. @SindriFr's PR has been CONFLICTING since develop gained RewriteContext, the rewrite_pipeline_stage/rewrite_pipeline_producer split (Tracking issue for safe pipeline rewriting #3171), hooks/decision.rs and let-chains, so shell_wrapper_depth is re-threaded through the current shape and the head foo expectations follow develop's --head-lines 10 default. Authorship and message are unchanged. If fix(rewrite): rewrite inner commands of quoted sh/bash/zsh -c wrappers #3828 lands first, this commit drops out of the diff; if it is easier to land this one, fix(rewrite): rewrite inner commands of quoted sh/bash/zsh -c wrappers #3828 closes with it.
  2. feat(rewrite): read a quoted fish -c script as fish — the actual subject of this PR, ~100 lines.

The fish half, scoped to the wrapper

#3828 left fish out because the shared lexer is bash-shaped and could not attest fish's syntax. The fix is not to teach that lexer a second grammar — it is to notice that the wrapper names the shell:

  • shell_wrapper.rs keeps the matched shell (ShellWrapper::dialect()), which supported_shell_end previously discarded;
  • lexer.rs gains ShellDialect and contains_unattestable_construct_in(cmd, dialect) — fish adds bare (…) substitution and a control keyword (and, or, not, begin, end, if, else, switch, case, for, while, function) at a command boundary;
  • registry.rs::rewrite_shell_wrapper passes wrapper.dialect() for the inner script.
fish -c 'git status; cargo test'          → fish -c 'rtk git status; rtk cargo test'
fish -c 'git status; and cargo test'        unchanged
fish -c 'git status (pwd)'                  unchanged
fish -c 'if test -d src; git status; end'   unchanged
fish -c 'not git status'                    unchanged

The fish rules reach that script and nothing else. A host command string stays bash — it is what the agent's Bash tool ran, and is_word_boundary_whitespace, advance_quote_state and NewlineMode::Bash all model bash. Verified against this branch's binary, isolated HOME/XDG_CONFIG_HOME:

git log -20 && (cd www && npm test)          → rtk git log -20 && (cd www && npm test)     (exit 3)
(cd sub && git status)                       → (cd sub && rtk git status)                  (exit 3)
git status; if true; then echo x; fi         → rtk git status; if true; then echo x; fi    (exit 3)
cargo build; for f in *.rs; do echo $f; done → rtk cargo build; for f in *.rs; do …        (exit 3)
ls -la # don't                               → rtk ls -la # don't                          (exit 3)
cargo build # check (fast)                   → rtk cargo build # check (fast)              (exit 3)
bash -c 'git status; cargo test'             → bash -c 'rtk git status; rtk cargo test'    (exit 3)

Those are exactly the lines blocker 1 measured as lost, plus blocker 2's comment cases. test_fish_rules_do_not_reach_bash_commands (registry) and test_posix_dialect_keeps_subshells_and_keywords_attestable (lexer) pin them so the reach cannot come back silently.

What is deliberately not here

  • The global (…)/keyword deferral. Dropped, not relocated. It existed in feat(shell)!: explicit shell execution, fish-aware hook gate, and fish -c wrapper rewriting #3911 to serve the fish-script wrapping feature, which is now PR C's subject.
  • has_unclosed_quote_or_escape in the global gate (the review's PR D, and the $'\'' auto-allow hole). Not in this PR. @KuSh has an unmerged branch closing the same hole (ansi_c_quote_defeats_lexer) including the #-comment fix blocker 2 describes — that one should land on its own; nothing here depends on it.
  • Fish-aware permission segmentation. split_for_permissions stays bash-shaped, so with deny: ["Bash(rm:*)"], fish -c 'true; and rm -rf /tmp/zz' is not denied by RTK — but it is also not rewritten (the dialect gate refuses it), so the host evaluates the original command exactly as it does today. fish -c 'rm -rf /tmp/zz' is denied, as are the bash -c forms. No regression against the merge-base, which denied none of them.

Key files

File Purpose
src/discover/shell_wrapper.rs SUPPORTED_SHELLS carries a dialect per shell; ShellWrapper::dialect()
src/discover/lexer.rs ShellDialect, contains_unattestable_construct_in
src/discover/registry.rs Inner script attested under the wrapper's own grammar
tests/rewrite_shell_wrapper_test.rs fish portable rewrite, fish-specific passthrough

Test plan

Tested on macOS 26.6.2 arm64, rustc 1.97.1.


Split siblings: A — #4125, the argv/--shell change with the exit-127 fix (blocker 3), independent of fish. C — fish-script wrapping at the hook gate, opened separately so its premise can be argued without holding A and B.

Rebased onto develop @ 0924356.

@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

✨ Type feature
🟡 Risk medium

Summary

Rewrites the inner script of a quoted fish -c '...' wrapper by teaching the lexer a fish-aware dialect check that defers on fish-only constructs like bare (...) substitution and control keywords, while keeping bash command strings and POSIX wrappers unaffected. This is PR B of a three-way split of #3911, rebasing #3828 onto develop and adding the fish-specific attestation logic on top.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

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

@KuSh KuSh left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1 of 3 — APPROVED

Claim: the inner commands of a quoted fish -c '…' wrapper are rewritten, with fish's own
syntax — bare (…) substitution and control keywords at a command boundary — deferring, while
every other string RTK lexes stays bash.

Scope: accept — this is exactly the narrowing round 1 of #3911 asked for, and the
measurement that motivated it holds: the (…)/keyword rules no longer reach a bash command
string. (round 1, frozen)

Ran: merge-base 0924356b, #3828's commit alone a3fa0c05, head 9ad3061f, and
head-merged-onto-b748a5f7, release-built with one CARGO_TARGET_DIR each (md5 ec117312… /
b52f3ba4… / 711e47f0… / 126ad4d3…, all four distinct). 70 hand-built rtk rewrite rows across the
claim, the fish deferrals, #3911 blocker 1's bash regressions, an adversarial fish corpus and
every operator spelling, plus a 655-script generated corpus; a round-trip differential
under real fish 4.9.1 (debian:sid, argv-logging shims) comparing what the original and
the rewritten script actually execute — on the head, 269 of the 655 are rewritten and 35 of
those run something different
, every one of them a &| script; the
permission path with deny: [Bash(rm:*)] and allow: [Bash(fish:*), Bash(git:*)], through both
rtk hook claude and rtk rewrite's verdict exit codes; and the project's own /code-review high over the commit. Invariants: every branch of the new decision (Posix-attest, fish-attest,
fish-paren defer, fish-keyword defer), under the hook (rtk rewrite first, then the returned
command run under fish), split streams n/a, no capping path in the diff. Savings the rewrite
unlocks inside the wrapper: git log -20 18179 → 5062 bytes = 72.2% vs the 20% floor
(CONTRIBUTING.md).

Matrix: never executed on this PR — running now.

CI @2dbb9d16: all green — 10/10 jobs, including test (windows-latest),
test (macos-latest), test (ubuntu-latest), fmt, clippy, benchmark, both security
scans and doc review; 11/11 checks pass on the PR. This is the first CI this branch has ever
had: run 35425334245 on 9ad3061f sat at action_required and was never built, so until now
neither of us had a Windows signal. Re-verified against current develop (f5e104e1, which
carries the v0.50.0 release merge): merges clean, cargo test --all 3968 passed / 0 failed,
exit 0.


Blocks merge (1, frozen at round 1) — fixed in 2dbb9d16, which I pushed; nothing owed by you

1. src/discover/lexer.rs — fish's &| is re-emitted as & |, and fish refuses to parse it.

&| is one operator in fish: it pipes stdout and stderr together. The shared lexer reads it as
a background & followed by a pipe, and the rewrite puts them back with a space between:

$ rtk rewrite "fish -c 'git status &| cargo test'"
merge-base:  <nothing>                                       (exit 1)
head      :  fish -c 'rtk git status & | cargo test'         (exit 3)

Under real fish 4.9.1 those two are not the same command:

$ fish -c 'echo hi &| cat'     hi          exit 0
$ fish -c 'echo hi & | cat'    fish: Expected a string, but found a pipe
                               echo hi & | cat
                                         ^      exit 127

The round-trip differential says it plainly — the original runs cargo test, the rewritten
form runs nothing at all:

git status &| cargo test  ->  rtk git status & | cargo test
   orig  (exit 0): cargo <test>
   after (exit 127): fish: Expected a string, but found a pipe

On develop this command passes through untouched and works. The PR routes it into the
rewrite and hands the host a script that does not parse, so it is a regression for every input
the new reach newly covers.

Class: operator spellings where the lexer's re-emission is not fish's spelling. I ran all
nine the wrapper can carry — &&, ||, ;, |, |&, &, 2>&1, 1>&2, >file — plus
their no-space forms, on both dialects. &| is the only one that changes meaning. |& and >file defer
outright; &&, ;, &, |, 2>&1 and 1>&2 rewrite to a form real fish executes
identically (spacing is normalized around the operator, the operator itself is not re-spelled).
So the class has one member, and closing it closes the class.

This one is mine, not yours. The ShellDialect shape here is the prototype I proposed in
#3911's round 1, and it had the same hole — I enumerated fish's control flow and substitution
and never looked at its operators. So I have written and verified the fix rather than sending
it back:

contains_fish_only_construct also returns true when a & token is immediately followed by a
pipe token, which is exactly &| and nothing else. POSIX wrappers keep rewriting it, correctly:
bash parses a &| b and a & | b identically — both are syntax errors, exit 2 — so nothing
changes there.

                                       head                              + fixup
fish -c 'git status &| cargo test'     rtk git status & | cargo test     — (defers)
fish -c 'git status&|cargo test'       rtk git status & |cargo test      — (defers)
fish -c 'git status & cargo test'      rtk git status & rtk cargo test   unchanged
fish -c 'git status | cat'             rtk git status | cat              unchanged
bash -c 'git status &| cargo test'     rtk git status & | cargo test     unchanged
git log -20 && (cd www && npm test)    rtk git log -20 && (cd …)         unchanged

With it, the same 655-script differential rewrites 234 and 0 run something different.
cargo fmt --all clean, cargo clippy --all-targets -- -D warnings clean, cargo test --all
3886 passed / 0 failed / 8 ignored, exit 0, with two new assertions pinning it at the lexer and
the wrapper level. A second pass by someone who had not seen my reasoning re-derived the defect,
diffed 130 further command strings between the two binaries and found no difference outside a
real unquoted &| in a fish wrapper — including the false-positive traps (&| inside single
and double quotes, \&\|, "a&"|cat) and multibyte bytes immediately before the &.

Optional — will not hold merge; I will resolve these threads

  • contains_fish_only_construct tokenizes with NewlineMode::Conservative and its doc says
    "Newlines are boundaries here, so a keyword opening a line of a multi-line script is seen" —
    but the only caller rejects script.contains(['\n', '\r']) two lines earlier in the same
    if chain, so the fish check never sees a newline. Swapping the mode to NewlineMode::None
    changes no test (3961 passed either way). The code is right if the newline rejection is ever
    relaxed; the comment just reads as a description of live behaviour, which it is not.
  • Adding fish to SUPPORTED_SHELLS also makes is_shell_wrapper_candidate true for it, so a
    fish -c wrapper can never take RTK's auto-allow. Checked and it costs nothing: with
    allow: [Bash(fish:*)], RTK emitted no permissionDecision for fish -c 'git status' on
    either binary, so the host applies its own rule exactly as before.

Checked and correct — no need to re-verify

  • #3911's blocker 1 is gone. All ten bash rows it measured as lost now match the merge-base
    byte for byte on the head: (cd sub && git status), git log -20 && (cd www && npm test),
    git status; if true; then echo x; fi, cargo build; for f in *.rs; do echo $f; done,
    git status; and cargo test, both #-comment lines, and the POSIX wrapper forms.
  • The fish wrapper is never auto-allowed. check_shell_wrapper_permissions returns Ask
    for any wrapper it parses; through rtk hook claude, fish -c 'git status' comes back with
    updatedInput and no permissionDecision, where a bare git status carries
    "permissionDecision":"allow".
  • Fish's single-quote escape cannot reach the rewrite. find_script_end refuses $,
    backtick and \ inside a double-quoted wrapper, and a single-quoted wrapper cannot contain a
    ' at all — so the one place fish and bash disagree about closing a quote is unreachable.
  • The keyword list is complete for the shapes that matter. command, builtin, exec,
    time, break, continue, return are not in it and do not need to be: each rewrites to a
    form real fish executes identically (verified in the container). break/continue/return
    need an enclosing block, whose opener is already refused.
  • Merges clean onto current develop (b748a5f7): cargo test --all 3961 passed, 0
    failed, exit 0, and every cfg(windows) path type-checks.
  • The docs match the code. Every claim in the README, src/discover/README.md,
    src/hooks/README.md and TECHNICAL.md hunks was run against the binary.

Hypotheses built and dropped

  • "Adding fish to the wrapper machinery lets a command escape a deny rule." The deep
    review flagged that split_for_permissions is bash-shaped, so and rm -rf … is one segment
    and the deny scan misses the rm. True, but not a regression and not fish-specific — at the
    real entry point, with deny: [Bash(rm:*)] (rtk rewrite exit code carries the verdict:
    2 = Deny, 3 = Ask, 1 = defer):

                                              merge-base   head
    rm -rf /tmp/zz                            DENY         DENY
    fish -c 'true; rm -rf /tmp/zz'            defer        DENY   ← new
    fish -c 'echo (rm -rf /tmp/zz)'           defer        DENY   ← new
    bash -c 'true && rm -rf /tmp/zz'          defer        DENY   ← new (#3828)
    fish -c 'true; and rm -rf /tmp/zz'        defer        defer
    bash -c 'true; and rm -rf /tmp/zz'        defer        defer
    

    The head adds three denials and removes none. The and row is unchanged from the merge-base
    and behaves identically under bash -c, because in bash and is just a command name and
    rm is its argument — so this is a property of split_for_permissions, not something fish
    introduces. Same conclusion as #3911's round-1 note, and the PR body already states it.

  • "The src/hooks/README.md hunk overstates what fish gets." It says RTK checks deny rules
    "against both the outer wrapper and the parsed inner segments", which is exactly what happens;
    it does not claim the inner segmentation is fish-aware, and the limitation is identical for
    the POSIX wrappers the sentence already covered. Left as written.

  • "Some other fish operator is mis-spelled by the rewrite too." &&|, &||, ;&|, >&|,
    2>&|, a trailing &| and &|& do not all take the new deferral — but each of them is
    already a fish parse error before any rewrite (fish -n, exit 127), so no runnable script is
    affected either way.

  • "A fish keyword after a separator the bash lexer does not split on will slip through."
    Ran the no-space and unusual-separator forms — ;and, ;and, && not, || and,
    | not, & and, >/dev/null; and — all defer. The tokenizer emits an operator for each of
    them, so command position is reached.

  • "The rebase silently re-attributes #3828's work." It does not: 07e17bab is already
    authored by pashifika and committed by Sindri Frostason, and a3fa0c05 keeps the same author
    and the same Co-authored-by trailer. Only the committer changes, as any rebase does.

  • "A rewrite rule emits POSIX-only syntax into what is now a fish script." No rule in
    rules.rs emits a shell operator; the rewrite only prefixes rtk and normalizes a few
    wrapper paths.

  • "^ is a fish stderr redirect and the rewrite will move it." fish -c 'git status ^err.txt'
    rewrites to rtk git status ^err.txt, which means the same thing under fish 2.x (redirect
    applies to rtk, which propagates) and under fish 3+ (a literal argument). Not reachable as a
    defect.

  • "src/core/filter.rs maps sh|bash|zsh to Language::Shell and should learn fish."
    Different subsystem (language-aware filtering of file contents), untouched by this PR, and
    pre-existing. Not this PR's job.

  • "Another open PR on these files conflicts." #4129–#4138 touch lexer.rs and
    registry.rs but none of them redefine the gate, SUPPORTED_SHELLS or ShellWrapper —
    their only overlap with those symbols is a doc-comment context line. They are mine to rebase.

Your questions

  • "[ ] CI for Linux and Windows" → Closed, and it was never your fault. No CI run had
    ever been created for this branch — 35425334245 sat at action_required on 9ad3061f and
    was never built, which is why the box stayed unticked through #3911 too. I approved the run on
    the fixup head: test (windows-latest), test (macos-latest) and test (ubuntu-latest) all
    pass, as do fmt, clippy, benchmark, both security scans and doc review. You now have
    the Windows signal you asked for.

Next

Nothing — approving. Fixup 2dbb9d16 pushed to your branch: a fish &| now defers instead of
being re-emitted as & |, plus two assertions pinning it at the lexer and the wrapper. It is a
behaviour change rather than pure wording, so if you would rather have written it yourself, or
disagree with deferring instead of re-spelling the operator, say so and I will unwind it — it is
your PR and the defect was mine.

This merges as soon as #3828 does.

Rounds: 1/3. Threads: 1 resolved this round (the &| blocker, closed by the fixup), 0 open.

pashifika and others added 4 commits October 4, 2026 08:31
An agent that reaches a project toolchain through a wrapper writes every
command as `direnv exec . bash -c '...'` or `devenv shell -- bash -c '...'`.
The script is one quoted argument, so the whole invocation was opaque and no
inner command was ever rewritten.

Parse the conservative subset of quoted `-c` wrappers and rewrite the script
in place, replacing only its byte span. The shell path, quote delimiters,
horizontal spacing, suffix arguments and fd redirects stay byte-identical.
Recursion is capped at one level, so a wrapper inside a wrapper stays raw.

A wrapper is reached at any prefix depth, so one sitting behind a transparent
prefix or a strippable `command` is still seen. A leading `sudo` keeps the
passthrough it has for bare commands: the inner `rtk` would run as root,
where it is off secure_path (rtk-ai#146).

Deferred unchanged: a script with a newline, heredoc, `$((`, active outer `$`
expansion, or any construct the shared lexer cannot attest; extra shell
options (`-lc`, `-e -c`); an unquoted or unbalanced script; and `fish`, whose
control keywords (`and`, `or`, `if`) at a command boundary need a fish-aware
lexer this does not model.

Permissions are evaluated on both sides of the new boundary: deny rules run
against the parsed inner segments, an inner deny outranks an outer allow, and
a wrapper rewrite is never auto-allowed — its strongest verdict is Ask, so a
`*` allow rule cannot approve a script the host never parsed.

Closes rtk-ai#2767

Co-authored-by: pashifika <pashifika.mkii@gmail.com>
The wrapper names the shell, so the script inside `fish -c '…'` is the one
string in the pipeline whose dialect RTK knows. The parser now reports it
(`ShellWrapper::dialect`) and the gate takes it
(`contains_unattestable_construct_in`): in fish, `(cmd)` is command
substitution rather than a subshell and `and`, `or`, `end` are control flow
rather than command names, so a script using either defers instead of being
rewritten under POSIX assumptions.

    fish -c 'git status; cargo test'          → fish -c 'rtk git status; rtk cargo test'
    fish -c 'git status; and cargo test'        unchanged
    fish -c 'git status (pwd)'                  unchanged
    fish -c 'if test -d src; git status; end'   unchanged

The fish rules reach that script and nothing else. A host's command string is
still lexed as bash — it is what the agent's Bash tool ran — so
`(cd sub && git status)`, `git log -20 && (cd www && npm test)` and
`git status; if true; then echo x; fi` keep rewriting, and so does the script
of a `sh`/`bash`/`zsh` wrapper.

Refs rtk-ai#2767
`&|` is one operator in fish: it pipes stdout and stderr together. The
shared lexer reads it as a background `&` followed by a pipe, and the
rewrite re-emits the two spaced, which fish rejects outright:

    fish -c 'cargo build &| tail -20'
      -> fish -c 'rtk cargo build & | tail -20'
      fish: Expected a string, but found a pipe

The command stops running altogether where it used to work. Treat the
pair as fish-only syntax, alongside bare `(…)` substitution and the
control keywords, so the script passes through untouched.

POSIX wrappers keep rewriting it: bash parses `a &| b` and `a & | b`
identically (both are syntax errors), so nothing changes there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ammar from an inner segment

Two defects in this PR, found by independent review of the stack built on it.

**`zsh -c '… &| …'` and `… &! …` are rewritten into a different script.**
Both are single operators in zsh — background and disown — and this lexer
reads each as a background `&` plus a pipe or a negation, so the rewrite
re-emits them spaced:

    zsh -c 'cargo build &| tail -3'   ->  'rtk cargo build & | tail -3'
    zsh: parse error near `|'

    zsh -c 'ls &! ls /nonexistent'    ->  'rtk ls & ! ls /nonexistent'
    original exits 1, rewrite exits 0

The second is the dangerous one: the spacing turns the following command into
a negated one, so a failing script is reported to the agent as success. `zsh`
was mapped to `ShellDialect::Posix`, which refuses neither, and the fish arm
only looked for `&|`. zsh now names its own dialect and the check covers both
pairs for every dialect that is not Posix — bash has neither operator, where
`a &| b` is a syntax error spaced or not, so nothing changes there.

**A denied command behind grammar escapes the wrapper's inner check.** The
outer gate matches each segment twice, raw and through
`strip_grammar_residue`; `check_shell_wrapper_permissions` matched only the
raw form, so with `deny: ["Bash(rm:*)"]`:

    bash -c 'git status; ! rm -rf victim'        rewritten (exit 3)
    bash -c 'git status && { rm -rf victim; }'   rewritten (exit 3)
    bash -c 'git status && (rm -rf victim)'      denied    (exit 2)
    git status; ! rm -rf victim                  denied    (exit 2)

Inconsistent across spellings of the same thing, and on Copilot IDE the
`{ … }` form rendered as a deny whose reason told the agent to re-run
`bash -c 'rtk git status && { rm -rf victim; }'` — RTK suggesting the command
the rule forbids. The inner loop now strips the same residue the outer one
does, which is what `src/hooks/README.md` already claimed.

Rebased onto `develop`; `tests/rewrite_shell_wrapper_test.rs` goes through
`common::rtk_command()` for the test-isolation guard that landed meanwhile.
@pashifika
pashifika force-pushed the feat/fish-c-wrapper branch from 2dbb9d1 to b969c3a Compare October 3, 2026 23:34
@pashifika

Copy link
Copy Markdown
Contributor Author

@KuSh — per your round-5 note on #4127, the two items you assigned here are pushed as b969c3a, on top of a rebase onto develop @ c356374 (this branch was DIRTY).

zsh -c and the pairs an ampersand opens. You asked for &|; &! turned out to be the same defect and the more dangerous half:

zsh -c 'cargo build &| tail -3'   ->  zsh -c 'rtk cargo build & | tail -3'
                                      zsh: parse error near `|'

zsh -c 'ls &! ls /nonexistent'    ->  zsh -c 'rtk ls & ! ls /nonexistent'
                                      original exits 1, rewrite exits 0

Both are background-and-disown in zsh and two tokens to this lexer, so the rewrite re-spaces them; the second turns the following command into a negated one, so a failing script is reported to the agent as success. zsh was mapped to ShellDialect::Posix, which refuses neither, and the fish arm only looked for &|. zsh now names its own dialect and the check covers both pairs for every dialect that is not Posix — bash has neither operator (a &| b is a syntax error spaced or not), so nothing there changes. tests/rewrite_shell_wrapper_test.rs pins the pass-through end to end, so mapping zsh back to Posix fails a test rather than only the dead-code lint.

The wrapper's inner deny check. It matched each inner segment raw, while the outer gate matches raw and through strip_grammar_residue. With deny: ["Bash(rm:*)"], allow: ["Bash(bash:*)"]:

bash -c 'git status; ! rm -rf victim'        was rewritten (3)   now denied (2)
bash -c 'git status && { rm -rf victim; }'   was rewritten (3)   now denied (2)
bash -c 'git status && (rm -rf victim)'      denied (2)          unchanged
git status; ! rm -rf victim                  denied (2)          unchanged

On Copilot IDE the { … } form rendered as a deny whose reason told the agent to re-run bash -c 'rtk git status && { rm -rf victim; }' — RTK suggesting the command the rule forbids. src/hooks/README.md:81 already claimed the inner segments are checked.

Also in the rebase: tests/rewrite_shell_wrapper_test.rs goes through common::rtk_command() for the test-isolation guard that landed on develop meanwhile.

cargo fmt --all --check                    clean
cargo clippy --all-targets -D warnings     clean
cargo test --all                           4,157 passed, 0 failed, 8 ignored
cargo check --target x86_64-pc-windows-gnu clean

#4127 is rebased on this head and carries nothing of it.

@KuSh

KuSh commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Round 2 — APPROVED

Claim: the inner commands of a quoted sh|bash|zsh|fish -c wrapper are rewritten, with each script attested under the grammar of the shell the wrapper names.

Scope: accept (round 1, frozen).

Ran: four release builds, one CARGO_TARGET_DIR each, md5 confirmed distinct — develop @0eddb84bb, the head I approved at round 1 @3e1188d9f, this head @b969c3a10, and b969c3a10 merged onto current develop @0921a209b. A 3,124-input generated corpus of &-shaped wrapper scripts (4 shells × operator spellings × glued/spaced/multibyte/quoted tails), r1 head vs this head: 174 inputs change, every one of them a &| or &! in a non-Posix dialect, 0 in bash/sh. Round-trip differentials under real zsh 5.9.2 and real fish 4.9.1 with argv-logging shims: every script that is still rewritten executes the same commands with the same exit code, and both pairs pass through. bash 5.3 and dash checked directly for the Posix exemption. Deny matrix of 8 spellings × 3 binaries under deny: ["Bash(rm:*)"]. #3828's own seven test cases × develop vs this head. cargo test --all merged onto current develop: 4,352 passed, 0 failed, exit 0; every cfg(windows) path type-checks. Invariants: every branch of the new decision (Posix / Zsh / Fish attestation, both pair spellings, glued and spaced), under the hook, split streams n/a, no capping path in the diff. This commit adds no filter and only narrows reach, so the 20% floor (CONTRIBUTING.md) is unmoved from round 1's 72.2% measurement.

Pins: 7 decisions, 7 mutated, 1 survived — disown_pair's TokenKind::Arg => value.starts_with('!') arm. It survives because it is unreachable, not because it is untested: the tokenizer flushes a bare ! into its own Shellism, and removing the arm changes the verdict on 0 of 3,124 inputs. Filed as #4517 rather than held, since no behaviour depends on it. The other six are killed by this PR's own tests, including the zsh mapping (revert ("zsh", Zsh) to Posix and test_zsh_dialect_refuses_the_pairs_an_ampersand_opens fails) and the inner deny strip (test_shell_wrapper_inner_deny_sees_through_grammar).

Matrix: executed this round; pin check: never before — executed this round.

CI @b969c3a10: 11/11 green, run 37162213915, including test (windows-latest), test (macos-latest), fmt, clippy, benchmark and both security scans.

Blocks merge (0)

The approval already on this PR stands and covers this head — GitHub kept it across your push, so
nothing here is waiting on a fresh review.

b969c3a10 fixes both items and does not change the approval. The two pairs are confirmed in real zsh 5.9.2, and &! is the dangerous half exactly as you described — it is a silent failure flip, not a parse error:

zsh -c 'false &! false'    exit 1      zsh -c 'ls /nonexistent &| tail -3'   exit 0
zsh -c 'false & ! false'   exit 0      zsh -c 'ls /nonexistent & | tail -3'  exit 1
                                       zsh:1: parse error near `|'

The r1 head rewrote both; this head defers both; develop never rewrote a zsh -c wrapper at all, so the defect was contained to this stack and never shipped. Your Posix exemption is right for both pairs, not just &|: bash and dash parse &! identically spaced or not, so nothing there changes.

The inner deny strip is consistent across spellings now — ! rm, { rm; }, ( rm ), the unwrapped form and every supported shell all deny, and a script with no denied command is untouched.

The class call in my round-1 review was wrong. I wrote that the operator class "has one member, and closing it closes the class" after enumerating nine spellings — but I enumerated them against fish and bash only, and zsh was mapped to Posix, so two of the three members were outside everything I ran. That is what left &! in the tree for three weeks, and it is also why the pin check now runs on every round.

Filed as follow-ups

Checked and correct — no need to re-verify

  • Both pairs, in real zsh, glued and spaced, and the Posix exemption in both bash and dash.
  • No over-refusal: of 3,124 corpus inputs only the 174 &|/&! ones change, and ordinary zsh — ;, &&, |, a background & with its own command, a standalone ! cargo test — still rewrites and round-trips identically under zsh 5.9.2.
  • My round-1 &| fix survived the rebase as 3e1188d9f with its authorship intact, and fish still defers &| while fish -c 'git status; cargo test' still rewrites.
  • The merge gate: merges clean onto current develop (66 commits on from your base) and the merged tree builds and passes 4,352 tests. Worth stating because feat(hooks): wrap unambiguously-fish scripts as rtk run --shell fish #4127, stacked on this head, is CONFLICTING and does not build merged — that is feat(hooks): wrap unambiguously-fish scripts as rtk run --shell fish #4127's problem, not this PR's.
  • &>|, &>>|, >&|, &;, &&!, |&! are not further members of the class: the first three are already refused by the file-target redirect check, and zsh parses the last three the same spaced or not.
  • strip_grammar_residue cannot over-deny: it strips only a standalone {, }, !, (, ) word, so find . ! -name x and test ! -f y are untouched, and bash -c 'git status; ! cargo test' stays undenied under deny: ["Bash(rm:*)"].

Your questions

Next

Nothing — approving. This no longer waits on #3828: it carries #3828's single commit with @pashifika's authorship intact, and six of #3828's own seven test cases behave on this head exactly as #3828 asserts while none of them works on develop. The seventh is fish -c 'git status; cargo test', which #3828 pins as pass-through and this PR deliberately rewrites — that is this PR's subject, so #3828's assertion is superseded, not broken. #3828 has not moved since 2026-09-19 and is now CONFLICTING; it should be closed in this PR's favour with credit to @SindriFr rather than merged first.

Rounds: 2. Threads: 0 open, 0 resolved this round.

@KuSh
KuSh merged commit 27a659f into rtk-ai:develop Oct 11, 2026
11 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Oct 11, 2026
aeppling pushed a commit that referenced this pull request Oct 12, 2026
#4126 made the rewrite descend through prefixes (env, assignments,
timeout, nice, nohup, time, exec, command, uv run, configured
transparent_prefixes) to a quoted shell wrapper, but the permission gate
still looked for the wrapper at the segment start only. A denied inner
command rode the outer allow: `env bash -c 'git push'` under
`allow env:*` + `deny git push:*` was rewritten and auto-allowed.

The gate now checks deny rules against the wrapper's script at every
position the rewrite reaches (`registry::shell_wrapper_positions`, the
rewrite's own prefix walk), on the text the rewrite reads: continuations
joined, segments not cut at a redirect (`lexer::split_for_wrappers`). A
match returns the new `PermissionVerdict::Defer`: no rewrite, no
decision, the host sees the command as typed — master's behaviour,
instead of #4126's Deny. Nothing else changes: a prefixed wrapper
otherwise keeps the outer command's verdict, and a wrapper at the
segment start keeps develop's rule.

Fixes the release blocker reported on #4399.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
aeppling pushed a commit that referenced this pull request Oct 13, 2026
#4126 made the rewrite descend through prefixes (env, assignments,
timeout, nice, nohup, time, exec, command, uv run, configured
transparent_prefixes) to a quoted shell wrapper, but the permission gate
still looked for the wrapper at the segment start only. A denied inner
command rode the outer allow: `env bash -c 'git push'` under
`allow env:*` + `deny git push:*` was rewritten and auto-allowed.

The gate now checks deny rules against the wrapper's script at every
position the rewrite reaches (`registry::shell_wrapper_positions`, the
rewrite's own prefix walk), on the text the rewrite reads: continuations
joined, segments not cut at a redirect (`lexer::split_for_wrappers`).
Inside the script the same walk is applied before matching, so a prefix
there (`exec git push`, `timeout 5 git push`, `FOO=1 git push`) reaches
the rule too. A match returns the new `PermissionVerdict::Defer`: no
rewrite, no decision, the host sees the command as typed — master's
behaviour, instead of #4126's Deny. Nothing else changes: a prefixed
wrapper otherwise keeps the outer command's verdict, and a wrapper at
the segment start keeps develop's rule.

Fixes the release blocker reported on #4399.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants