Skip to content

fix(hook): defer on a denied command inside a prefixed shell wrapper - #4530

Merged
aeppling merged 1 commit into
developfrom
fix/shell-wrapper-prefix-permission
Oct 13, 2026
Merged

aeppling merged 1 commit into
developfrom
fix/shell-wrapper-prefix-permission

Conversation

@aeppling

@aeppling aeppling commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fixes the release blocker reported on Next Release #4399 (comment): a regression from feat(rewrite): rewrite inner commands of a quoted fish -c wrapper #4126.
  • feat(rewrite): rewrite inner commands of a quoted fish -c wrapper #4126 made the rewrite descend through prefixes to a quoted shell wrapper (env bash -c '…'), 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' with allow env:* + deny git push:* was rewritten and auto-allowed (master: no rewrite, host decides). Same for timeout 5, nice, nohup, time, exec, command, uv run and a configured transparent_prefixes entry.
  • The gate now checks deny rules against a wrapper's script at every position the rewrite can reach it (registry::shell_wrapper_positions, the rewrite's own prefix walk). A deny match returns Defer: no rewrite, no decision, the host sees the command exactly as typed — master's behaviour, instead of feat(rewrite): rewrite inner commands of a quoted fish -c wrapper #4126's Deny.
  • Nothing else changes: a wrapper behind a prefix otherwise keeps the outer command's verdict, exactly as develop and master. Develop's rule for a wrapper at the segment start (never auto-allowed, Ask) is kept as is.
  • The wrapper check runs on the same text as the rewrite: line continuations are joined first (registry::collapse_line_continuations), so bash -c 'git \⏎push' and bash \⏎-c 'git push' reach the deny rule like bash -c 'git push' does. The second form was auto-allowed on develop.
  • The wrapper check also segments without truncating at a redirect (lexer::split_for_wrappers). The rule gate's own split cuts a segment at its first redirect, so FOO=a>/dev/null bash -c 'git push' showed it only FOO=a while the rewrite, whose env peel swallows FOO=a>/dev/null, rewrote the script. Auto-allowed on develop, closed here. (Rule matching itself is unchanged; the plain FOO=a>/dev/null git push is the same on master and develop.)
  • Inside the script, the same prefix walk is applied before matching the deny rules, so a prefix there reaches the rule too: env bash -c 'exec git push', 'command git push', 'env git push', 'timeout 5 git push', 'FOO=1 git push' defer. Auto-allowed on develop (the inner match was literal), closed here.
allow Bash(*), deny git push:* master develop this PR
env bash -c 'git push' 1 0 (allow) 1
timeout 5 bash -c 'git push' && ls 0 0 (allow) 1
direnv exec . bash -c 'git push' (prefix configured) 1 0 (allow) 1
bash \⏎-c 'git push' 1 0 (allow) 1
bash -c 'git \⏎push' 1 3 1
FOO=a>/dev/null bash -c 'git push' 1 0 (allow) 1
FOO=a&>/dev/null timeout 5 bash -c 'git push' 1 0 (allow) 1
env bash -c 'exec git push' 1 0 (allow) 1
env bash -c 'timeout 5 git push' 1 0 (allow) 1
bash -c 'exec git push' 1 3 1
env zsh -c 'git status' && ls 0 0 0
env zsh -ec 'git status' && ls 0 0 0
bash -c 'git status' && ls 0 3 3 (develop's rule, unchanged)
bash -c 'git push' 1 2 1
grep -rn bash -c src 0 0 0

(exit: 0 allow, 1 no rewrite, 2 deny, 3 ask)

Review history

  • v1 scanned every token for a wrapper: prompted on ordinary commands (grep -rn bash -c src), dropped rewrites, quadratic. Replaced.
  • v2 gated every reachable wrapper as Ask: prompted on wrappers whose script RTK did not change (env zsh -ec … && ls). Replaced.
  • v3 asked only when the rewritten text lost the quoted script: compared raw text against the joined rewrite (continuation bypass) and scanned the command once per wrapper (quadratic). Reverted.
  • v4 (this PR, squashed to one commit): the gate adds exactly one thing to develop — the deny check at the positions the rewrite reaches, on the text the rewrite reads (continuations joined, segments not cut at a redirect) — and changes nothing else. Equal to develop and master everywhere else by construction; the differential fuzz below asserts exactly that. KuSh's review of v3: blockers 1 (continuation) and 2 (quadratic) are gone with the string counting; should-fix 3 (glued redirect) is closed by split_for_wrappers; 4 (drift test) is now a verdict-level test over his rows (test_wrapper_reached_by_the_rewrite_is_gated_for_deny_rules); 5 (stale exit-code docs in main.rs, rewrite_cmd.rs, decision.rs) updated.

Related

Not changed (same as master, for v1 — tracked in #4545)

  • A wrapper the rewrite does not reach is not gated: find . -exec bash -c '…' \;, ssh host bash -c '…', xargs sh -c '…'.
  • A wrapper behind a prefix whose script RTK rewrites is auto-allowed on the outer rule, as on develop.
  • A denied command hidden behind a program's own options inside a script is not matched: env bash -c 'git -C . push' → exit 0, as on develop. The host does not read past git -C either; fix(permissions): match deny and ask rules past assignments and wrappers (#4281) #4284 draws the same line.

Test plan

  • cargo fmt --all && cargo clippy --all-targets && cargo test --all && RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps — 4390 tests pass, 0 warnings
  • Reproducers on master / develop / this binary: all rows above; every probe from the four reviews (prefixed denied forms incl. uv run and configured direnv exec .; nine non-wrapper commands; four untouched-wrapper rows; five continuation rows; glued-redirect rows incl. ls && FOO=a>/dev/null bash -c '…'; trailing and leading redirects on a wrapper; cat >file bash -c '…' stays untouched; find -exec; suffix -- sh -c; deny word in argument text; 10 vs 11 stacked exec)
  • Timing vs develop: 5,000 rewritten wrappers 0.06 s / 0.06 s; 40,000 untouched wrappers + ls 0.27 s / 0.26 s; 100k args 0.05 s / 0.05 s; 1 MiB wrapper hook payload 0.18 s / 0.14 s
  • Differential fuzz vs develop and master — 546,480 process runs on this head (4,048 commands × 5 permission profiles × 3 binaries × rtk rewrite + 8 hook formats). Generator: prefixed wrappers, -lc/-ec/-x -c/-c -- forms, prefixes inside the script (exec, command, env, timeout, assignments, git -C), shell names as arguments, quoted shell paths, suffix -- sh -c, stacked prefixes, configured transparent_prefixes, pipelines, groups, random \⏎ continuations, redirects glued to assignments, every probe from the five reviews. Oracle: the PR must equal develop on every format except a Defer on a denied inner script or develop's Deny; any new block or auto-allow vs master and develop, any rewrite-text difference, any crash or invalid hook JSON fails. 0 violations, 0 flagged rows. Every change is a Defer on a denied inner script (1,645), develop's Deny→Defer (70), or a joined continuation (25). Timing suite: identical to develop within noise (40,000 untouched wrappers 0.42 s vs 0.41 s; 1 MiB wrapper hook 0.25 s vs 0.17 s). Earlier heads: 548,505 and 547,155 runs, 0 regressions each.

🤖 Generated with Claude Code

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Adrien EPPLING seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

🐛 Type bug-fix
🔴 Risk high

Summary

Fixes a permission-check regression where a deny rule inside a shell wrapper (e.g. bash -c 'git push') could be bypassed when the wrapper was preceded by a prefix like env, timeout N, or an env var assignment. Inner-wrapper denies now return a new Defer verdict (passthrough, no rewrite/decision) instead of Deny, restoring master's behavior while scanning all tokens in a segment for wrapper candidates rather than just the first.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Linked issues: #4399, #4126


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

@pszymkowiak pszymkowiak 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.

LGTM. Verified locally: fmt/clippy clean, cargo test --all green, and rtk rewrite matches the table (prefixed/sudo/xargs/nested wrappers with an inner deny all defer; plain git push still exits 2).

Non-blocking nits:

  • PermissionVerdict::Defer has no doc comment, and check_command's precedence doc doesn't mention it.
  • A one-line test that decide_with_params(…, Defer) yields HookDecision::Defer would pin the new gate.
  • Side effect worth noting in the description: since wrappers are detected at any arg position, find . -exec sh -c '…' \; with allow find:* now gets Ask instead of Allow (stricter, fine by me).

CLA still pending on the commit email.

@aeppling aeppling changed the title fix(hook): defer when a shell wrapper's script hits a deny rule fix(hook): gate shell wrappers where the rewrite reaches them Oct 12, 2026

@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.

Review — request changes

The fix covers the env bash -c 'git push' regression it targets. However, deleting the is_shell_wrapper_candidate fallback opens a new auto-allow, and the new rewrite-detection check is quadratic.

Every finding below was reproduced against debug builds of this head (e4a200f) and develop (311ebcb).

  • Settings: allow Bash(*), deny Bash(git push:*)
  • rtk rewrite exit codes: 0 allow · 1 no rewrite · 2 deny · 3 ask

Blocking

1. A line continuation hides the wrapper from the gate, so a rewritten script is auto-allowed (regression)

Command this PR develop
bash -c \⏎'git push' 0, bash -c 'rtk git push' 3
bash -c 'git status'\⏎ && ls 0 3

The rewriter runs collapse_line_continuations (registry.rs:796) before it parses wrappers. The gate segments the raw cmd (permissions.rs:118). parse_shell_wrapper therefore fails on the gate's text, and the segment is skipped. Before this PR, is_shell_wrapper_candidate turned this case into Ask.

Suggested fix: collapse continuations on cmd at the top of check_command_with_rewrite_params, before segmenting.

2. The rewritten-script check is quadratic

permissions.rs:229-231 runs rewritten.matches(quoted).count() and cmd.matches(quoted).count() for each wrapper. Timings for bash -c 'git status' repeated, joined by ; :

wrappers develop this PR
1 000 0.16 s 0.46 s
2 000 0.19 s 1.29 s
4 000 0.40 s 4.33 s

The hook stdin payload has no argv limit, so a MiB-scale command is much worse. The "linear" timing in the description was measured on inputs that don't exercise this path (100k plain args, a single large script).

Suggested fix: per wrapper position, compare against the rewriter's own rewrite_shell_wrapper result instead of counting substrings. That is O(n), and it also removes the reliance on text matching, which breaks silently if the rewriter ever re-quotes or re-escapes.

Should fix

3. A redirect glued to an env assignment hides the wrapper

  • FOO=a>/dev/null bash -c 'git push' is rewritten to FOO=a>/dev/null bash -c 'rtk git push' with exit 0.
  • FOO=a&>/dev/null timeout 5 bash -c 'git push' behaves the same.

develop also returns 0 here, so this is not a regression. It is, however, the class of bug this PR sets out to close. ENV_PREFIX (registry.rs:69, [^\s]*) swallows >/dev/null into the value, so the rewrite strips the prefix and reaches the wrapper. split_for_permissions instead cuts the segment at the redirect token, so the gate never sees the wrapper.

4. The drift test can't catch 1 or 3

test_shell_wrapper_positions_match_where_the_rewrite_reaches calls shell_wrapper_positions on pre-split strings. It never goes through split_for_permissions, never normalizes the command, and never checks the verdict.

Suggested: assert "rewrite reaches a wrapper ⇒ check_command_with_rewrite_params ≠ Allow" for these rows:

  • bash -c \⏎'git status'
  • bash \⏎-c 'git status'
  • timeout 5 \⏎bash -c …
  • true &&\⏎bash -c …
  • FOO=a>/dev/null bash -c …
  • FOO=a&>/dev/null timeout 5 bash -c …
  • 10 vs 11 stacked exec prefixes
  • a case with non-empty exclude patterns

5. Exit-code docs are stale

An inner-script deny now exits 1, not 2. These places still describe exit 2 as "a deny rule matched (checked before anything else)" or omit Defer:

  • src/main.rs:987 (rtk rewrite --help)
  • the run table at src/hooks/rewrite_cmd.rs:70
  • the gate-order doc at decision.rs:122-139

Non-blocking

  • Prefixed denied command inside a script → Ask, not Defer. env bash -c 'git status && timeout 5 git push' returns 3 (a prompt, not an allow). The inner deny match is literal, the same as the outer loop.
  • Prefixed denied command outside a wrapper is auto-allowed. timeout 5 git push, env git push and FOO=1 git push return 0 on this PR and on develop. Out of scope here, but it is the same family.
  • test_defer_verdict_maps_to_passthrough_exit_code doesn't exercise run. It only checks the test-local expected_exit_code, so deleting the Defer => exit(1) arm would not fail it. A subprocess test via common::rtk_command() asserting exit 1 and empty stdout would cover it.
  • No end-to-end coverage of check_command_for_agent with configured transparent_prefixes, and no case where an ask rule matches an inner command.
  • Spurious Defer, safe direction. ls && ( bash -c '…' ) and a middle pipeline stage (echo a | bash -c '…' | cat) defer, which drops the ls rewrite.

Checked and OK

  • The prefix walk matches rewrite_segment_inner branch by branch: depth bound, RTK_DISABLED=, routable uv run, user transparent_prefixes.
  • Defer is handled by every consumer. All host hook formats pass the command through unrewritten, and the discover coverage estimate excludes it.
  • quoted_script offsets always land on char boundaries.
  • Deny → Defer has no effect on the in-tree delegates: hermes treats exits 1 and 2 identically, and pi rewrites only on 0 and 3.
  • Gate checks pass: fmt, clippy (0 warnings), cargo doc -Dwarnings, and 4,075 lib/bin tests. guard_integration_test::git_log_malformed_digit_run… and copilot_selfheal_test::unwritable_hooks_dir… fail in the same way on develop; the causes are environment-specific (git error wording, running as uid 0).

Generated by Claude Code

@aeppling aeppling changed the title fix(hook): gate shell wrappers where the rewrite reaches them fix(hook): defer on a denied command inside a prefixed shell wrapper Oct 12, 2026
@aeppling
aeppling force-pushed the fix/shell-wrapper-prefix-permission branch from 3de213d to ae46f1d Compare October 12, 2026 19:04
@aeppling

Copy link
Copy Markdown
Contributor Author

Squashed to one commit (ae46f1db). The approach is reduced to the smallest change that is never worse than master: the gate adds only a deny check on a wrapper's script at every position the rewrite reaches (→ Defer, passthrough), on the same text the rewrite reads (continuations joined, segments not cut at a redirect). A prefixed wrapper otherwise keeps the outer command's verdict, as on develop and master; develop's segment-start rule is unchanged.

@KuSh — against your review of e4a200f5:

  1. continuation bypass and 2. quadratic check: gone with the string counting (the wrapper check reads the joined text; nothing is counted).
  2. FOO=a>/dev/null bash -c 'git push': closed. The rule gate's split_for_permissions cuts a segment at its first redirect; the wrapper check now uses lexer::split_for_wrappers, the same split without that cut. Rule matching itself is unchanged (the plain FOO=a>/dev/null git push is the same on master).
  3. drift test: your rows are verdict assertions now (test_wrapper_reached_by_the_rewrite_is_gated_for_deny_rules: both continuation forms, timeout 5 \⏎bash -c, true &&\⏎bash -c, both glued redirects, 10 vs 11 stacked exec), asserting rewrite_command reaches the wrapper ⇒ Defer.
  4. exit-code docs in main.rs, rewrite_cmd.rs and decision.rs updated for Defer.
    Non-blocking items (prefixed deny inside a script, timeout 5 git push outside a wrapper) are fix(permissions): match deny and ask rules past assignments and wrappers (#4281) #4284's scope; the spurious Defer on ls && ( bash -c '…' ) is kept (safe direction).

Fuzz on this content: 548,505 runs vs master and develop, oracle "equal to develop except Defer on a denied inner script", 0 violations, 0 flagged rows (details in the description). Trade-off kept on purpose for the release: a prefixed wrapper whose script RTK rewrites stays auto-allowed on the outer rule, as develop does today — the recorded-scan design (#3828) is the v1 follow-up.

@pszymkowiak — the approach changed since your approval of 5ef29f81; please re-look.

@aeppling
aeppling requested review from KuSh and pszymkowiak October 12, 2026 19:43

@pszymkowiak pszymkowiak 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.

Re-looked at ae46f1db as asked.

Locally: fmt and clippy clean, cargo test --all 4391 green, and KuSh's five points all check out on my side (continuation, quadratic, glued redirect, drift test, exit-code docs). Every prefixed-wrapper row from the table reproduces, including env -i, env -u X, timeout --signal=KILL 5, timeout -k 2 5, nohup … &, ( … ), trailing >/dev/null, 2>&1 | cat, $'…', and a nested bash -c 'bash -c "git push"'. The fix for the #4126 regression is right and the shell_wrapper_positions drift test is a good guard.

Two points inline before I re-approve, both reproduced on this head with allow Bash(*) + deny Bash(git push:*) in project settings and an empty HOME:

  1. A prefix inside the script still gets a rewritten, prefixed wrapper auto-allowed (env bash -c 'exec git push' → exit 0). Not a regression, same family as the one this PR closes.
  2. Deny → Defer removes develop's hard block on hosts where RTK renders the decision itself.

CLA is still unsigned on the commit email.

Comment thread src/hooks/permissions.rs
continue;
};
if depth == 0 {
contains_shell_wrapper = true;

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.

A prefix inside the script defeats the inner deny check, and because the wrapper is at depth 1 nothing caps the verdict at Ask

Not a regression (develop: 0, master: 1), but it is the same family as env bash -c 'git push'. Repro on ae46f1db, allow Bash(*) + deny Bash(git push:*) in .claude/settings.json, empty HOME:

$ rtk rewrite "git push"                      # exit 2 — deny rule is loaded
$ rtk rewrite "env bash -c 'git push'"        # exit 1 — this PR ✔
$ rtk rewrite "env bash -c 'exec git push'"
env bash -c 'exec rtk git push'               # exit 0
$ echo '{"tool_name":"Bash","tool_input":{"command":"env bash -c '"'"'exec git push'"'"'"}}' | rtk hook claude
{"hookSpecificOutput":{…,"updatedInput":{"command":"env bash -c 'exec rtk git push'"},"permissionDecision":"allow"}}

Same with command git push, env git push, timeout 5 git push and git -C . push inside the script.

Why: the inner match is literal, so exec git push does not match git push:*. That part is #4284's scope, agreed. But the wrapper sits at depth 1, so this depth == 0 guard skips develop's "a wrapper is never auto-allowed, at most Ask" rule, the outer env matches allow *, and the rewritten script is auto-allowed. Unprefixed bash -c 'exec git push' gets 3 (Ask) from the segment-start rule — the prefix is what removes the guard.

Suggestion: set contains_shell_wrapper = true whenever parse_shell_wrapper succeeds, at any depth (keep the depth == 0 guard on the is_shell_wrapper_candidate fallback above so grep -rn bash -c src stays untouched). Cost: env bash -c 'git status' becomes Ask instead of Allow — the verdict develop already gives bash -c 'git status', so it is consistent, and env zsh -ec 'git status' && ls would prompt the way bash -c 'git status' && ls already does (the v2 trade-off KuSh pushed back on; I think the segment-start rule has already accepted that cost). The two *_keeps_the_outer_verdict tests would flip to Ask. Benefit: RTK never answers "allow" on a wrapper script it did not fully understand.

If you'd rather keep that for the #3828 follow-up, please at least list env bash -c 'exec git push' → exit 0 explicitly under "Not changed", so the release notes are honest about it.

Comment thread src/hooks/permissions.rs
})
});
if inner_denied {
return Some(PermissionVerdict::Defer);

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.

Deny → Defer downgrades develop's hard block on hosts where RTK renders the decision

bash -c 'git push' with the same rules: develop exits 2 (Deny), this PR exits 1 (Defer). The description frames that as master's behaviour, but master is the weaker baseline; develop had moved forward and the 68 Deny→Defer rows in your fuzz are exactly this.

Impact per host, from hook_cmd.rs on this head:

  • Claude Code: none. process_claude_payload turns Deny into PayloadAction::Skip, run_claude_inner prints nothing for anything but a rewrite, so Deny and Defer both render as no output and Claude applies its own rules (I checked: rtk hook claude prints nothing for git push and for bash -c 'git push').
  • Gemini: Deny renders {"decision":"deny",…}, Defer renders ask_user. Antigravity: Deny renders "decision":"deny", Defer renders {"decision":"allow"}. Those hosts do not parse -c scripts, so their own deny git push:* does not match bash -c 'git push' — with this PR the block is simply gone for them. Trae collapses Deny and Defer into None, so it is unaffected; I did not check every remaining host.

The inner match is literal (git push, rm), so false positives are rare; a literal deny match inside a script RTK fully parsed looks like exactly the case where RTK should keep saying Deny. Could you either keep Deny here — at least at depth 0, which is develop's tested behaviour — or say why a confirmed inner deny should hand the decision back to a host that cannot see it? If the concern is #4126's Deny on a wrapper the rewrite would not touch anyway, develop already does that today and nobody has reported it.

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.

Correction on my side: the Gemini and Antigravity part of this comment was wrong. I read how each hook renders a Deny and did not check which rules each host loads. load_rules_for gives Gemini its own allow/confirm rules (load_gemini_rules) and Antigravity, Codex, Trae and Vibe no RTK-side rules at all; none of them reads .claude/settings.json. So a Claude deny rule never produced a Deny for those hosts, on develop or anywhere else, and there was no block to lose there.

The only host where develop actually blocked bash -c 'git push' is Copilot IDE, which decides with Host::Claude rules and renders Deny as permissionDecision: deny. That block came in with #4126, which is not on master (v0.51.0), so it was never released. With that, Defer being "never worse than master on any host" holds, and I withdraw the request to keep Deny here for the release. Whether a literal inner deny match should become a hard Deny at depth 0 is a product call for the maintainers; your one-line offer stands if they want it.

#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>
@aeppling
aeppling force-pushed the fix/shell-wrapper-prefix-permission branch from ae46f1d to 4d37aa8 Compare October 13, 2026 09:10
@aeppling

Copy link
Copy Markdown
Contributor Author

@pszymkowiak thanks — both points reproduced on release binaries of master, develop and this head; 4d37aa8b (still one commit) answers them.

1. Prefix inside the script — closed. The inner deny match now applies the same prefix walk the gate already uses (registry::shell_wrapper_positions) to each inner segment before matching, so exec, command, env, assignments, timeout, nice, nohup inside the script reach the rule:

allow Bash(*), deny git push:* master develop 4d37aa8b
env bash -c 'exec git push' 1 0 1 (rtk hook claude: no output)
… 'command git push', 'env git push', 'timeout 5 git push', 'FOO=1 git push' 1 0 1
bash -c 'exec git push' 1 3 1
env bash -c 'git -C . push' 1 0 0 — listed under "Not changed"

I kept the narrow form rather than "any parsed wrapper → Ask at every depth": that would prompt on every parseable prefixed script, rewritten or not (env bash -c 'git status' under allow env:*, env zsh -c '{ git status; }' && ls), which KuSh pushed back on in v2 and which master never prompts for. (Small correction to the cost estimate: env zsh -ec … && ls would not prompt under your suggestion either — -ec fails the parser and the candidate fallback stays at depth 0.) git -C . push stays unmatched: the host does not read past a program's own options either, and #4284 draws the same line. Test: test_inner_deny_sees_past_a_prefix_inside_the_script.

2. Deny → Defer. Per host, bash -c 'git push' with the deny in .claude/settings.json:

Host master develop this PR
Claude Code no output no output no output
Gemini ask_user ask_user + rewrite ask_user + rewrite (identical to develop)
Antigravity allow allow + rewrite allow + rewrite (identical to develop)
Copilot JetBrains no output "permissionDecision":"deny" no output
Trae — unaffected unaffected

Gemini and Antigravity never receive a Deny from RTK: load_rules_for gives Gemini its own allow/confirm rules only (permissions.rs:393-401) and Antigravity none (:90); .claude/settings.json is not read by those hooks, so there was no block to lose there. The one host where develop blocked is Copilot JetBrains, as the description states — that block came in with #4126 and is not on master. Defer is kept on purpose: it is the only verdict that is never worse than master on any host (RTK neither rewrites nor decides, the host applies its own rules to the command as typed). If the maintainers prefer develop's Deny at the segment start for the release, it is a one-line change in check_shell_wrapper_permissions and I will make it on request.

Gate on 4d37aa8b: fmt, clippy 0, rustdoc -D warnings, 4,390 tests. Probe set from all four reviews re-run on this head, every row as in the description. The differential fuzz seed for this head (inner-prefix shapes added to the generator) is running; I will post the figures here.

CLA: the commit email is not attached to the author's GitHub account; being handled on our side.

@aeppling
aeppling requested a review from pszymkowiak October 13, 2026 09:15

@pszymkowiak pszymkowiak 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.

Re-verified on 4d37aa8b (still one commit, on the tip of develop).

Gate: fmt and clippy clean, cargo test --all 4392 green, CI green on the three OS.

Point 1 — closed. Every inner-prefix row defers on my side, rtk hook claude prints nothing for them:

env bash -c 'exec git push'        exit 1   (was 0)
env bash -c 'command git push'     exit 1
env bash -c 'env git push'         exit 1
env bash -c 'timeout 5 git push'   exit 1
env bash -c 'FOO=1 git push'       exit 1
env bash -c 'nice -n 5 git push'   exit 1
env bash -c 'time git push'        exit 1
env bash -c 'nohup git push'       exit 1
env bash -c 'cd x && exec git push'      exit 1
env bash -c 'exec exec git push'         exit 1
env bash -c 'bash -c "exec git push"'    exit 1
env bash -c 'direnv exec . git push'     exit 1   (configured transparent_prefixes, inside and outside the script)
env bash -c 'git -C . push'        exit 0   → listed under "Not changed", fine
env bash -c 'exec git status'      exit 0   → rewritten, as expected

My 18 non-regression rows from the previous pass (redirects, pipes, env -i, timeout --signal, $'…', grep -rn bash -c src, git commit -m "bash -c …", …) give the same verdicts as on ae46f1db.

Point 2 — you were right and I was wrong about Gemini and Antigravity; see my reply in the thread. Defer is indeed never worse than master on any host. Deny at depth 0 is a product call for the maintainers, not a blocker for the release.

Timing, debug builds, same machine, 3 runs each, develop vs this head: 4000 bash -c 'git status' wrappers 0.43–0.48 s vs 0.45–0.63 s; 4000 env bash -c 'git status' wrappers 0.41–0.49 s vs 0.52–0.60 s (≈ +15 %, linear, on an unrealistic payload); one wrapper with 5000 inner segments plus a git push 0.40 s exit 0 on develop vs 0.08 s exit 1 here — develop rewrote and auto-allowed the whole script, this head stops at the deny.

Merge is still blocked on the CLA (license/cla: pending).

@aeppling

Copy link
Copy Markdown
Contributor Author

Fuzz figures for 4d37aa8b, as promised: 546,480 process runs vs master 0.51.0 and develop 311ebcbf (4,048 commands × 5 permission profiles × 3 binaries × rtk rewrite + 8 hook formats; generator now also puts exec/command/env/timeout/assignments/git -C inside scripts). Oracle: equal to develop on every format except a Defer on a denied inner script or develop's Deny; any new block or auto-allow vs master and develop, any rewrite-text difference, any crash or invalid hook JSON fails. 0 violations, 0 flagged rows. Changes seen: Defer on a denied inner script 1,645; develop's Deny→Defer 70; joined continuations 25. Timing identical to develop within noise (40,000 untouched wrappers + ls: 0.42 s vs 0.41 s). CI on this head: 17 checks green, CLA pending.

@aeppling
aeppling merged commit ffa5651 into develop Oct 13, 2026
19 of 20 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Oct 13, 2026
@KuSh
KuSh deleted the fix/shell-wrapper-prefix-permission branch October 13, 2026 12:14
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.

4 participants