Repository navigation
Conversation
29e56ff to
3317930
Compare
|
Rebased onto current Two notes while rebasing: A Overlap with #3057. Gates locally, on Linux x86_64 (NixOS): Only |
3317930 to
7518b89
Compare
KuSh
left a comment
There was a problem hiding this comment.
Round 1 of 3 — CHANGES REQUESTED
Claim: rewrite the inner commands of an exact quoted sh|bash|zsh -c wrapper, byte-preserving the wrapper itself, without letting a host allow rule approve a script the host never parsed.
Scope: accept — the mechanism is right, the subset is honestly narrow, and the byte-span approach is the correct way to do this. #3057 is closed; #3911 is stacked on this branch, so this lands first. (round 1, frozen)
Ran: merge-base 5e10aa64, head 7518b894 and head-merged-onto-develop built into three separate CARGO_TARGET_DIRs (md5 distinct). rtk rewrite and all six rtk hook <agent> entry points, differentially, against isolated HOME + XDG_CONFIG_HOME with real .claude/settings.json and rtk/config.toml; 611 wrapped cases generated from 321 real commands harvested from the repo's own rewrite tests; raw-vs-rewritten execution of wrapped pipeline stages. No filter output changes, so the 20% floor (CONTRIBUTING.md) does not apply.
CI @7518b894: only license/cla. Run 34109043724 is action_required — see "Your questions".
Blocks merge (2, frozen at round 1)
1. src/hooks/permissions.rs:140 — the permission path and the rewrite path disagree about where a wrapper can start, so a prefixed wrapper is rewritten and auto-allowed with its inner deny rule skipped.
check_shell_wrapper_permissions calls parse_shell_wrapper(segment) / is_shell_wrapper_candidate(segment) on the raw segment, and both require the shell to be the segment's first word. rewrite_segment_inner does not: it reaches the wrapper after stripping env assignments, SHELL_KEYWORD_PREFIXES, ROUTABLE_WRAPPER_PREFIXES, PROCESS_WRAPPERS and user transparent_prefixes. So contains_shell_wrapper stays false, the function returns None, and the ask/allow loop hands back Allow.
With ~/.claude/settings.json = {"permissions":{"allow":["Bash(*)"],"deny":["Bash(rm:*)"]}}, neutral cwd, rtk rewrite (0=Allow, 1=passthrough, 2=Deny, 3=Ask):
base head
bash -c 'git status; rm -rf /tmp/x' 1 2 ✓ inner deny fires
command bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
env bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
exec bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
builtin bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
noglob bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
uv run bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
FOO=bar bash -c 'git status; rm -rf /tmp/x' 1 0 ✗
nice bash -c 'git status; rm -rf /tmp/x' 1 0 ✗ (post-rebase only, #2375)
nohup bash -c 'git status; rm -rf /tmp/x' 1 0 ✗ (post-rebase only, #2375)
timeout 30 bash -c 'git status; rm -rf /tmp/x' 1 0 ✗ (post-rebase only, #2375)
direnv exec . bash -c 'git status; rm -rf /tmp/x' 1 0 ✗ (transparent_prefixes)
End to end through the Claude hook, same settings:
$ echo '{"tool_name":"Bash","tool_input":{"command":"command bash -c '\''git status; rm -rf /tmp/x'\''"}}' | rtk hook claude
develop: (no output — RTK stays out of it, the host evaluates the original command)
head: {"hookSpecificOutput":{...,"updatedInput":{"command":"command bash -c 'rtk git status; rm -rf /tmp/x'"},
"permissionDecision":"allow"}}
This is a regression against develop, which auto-allowed nothing on these inputs, and it inverts the three invariants the PR states: "deny rules are also evaluated against the inner commands", "a wrapper rewrite is never auto-allowed", and "One parser backs both rewriting and permission evaluation, so the two paths agree on the boundary." direnv exec . bash -c '…' is the PR's own headline example. Every host that honours AllowRewrite is affected.
Class, not instance: the covered set is exactly one shape (bare wrapper) out of every prefix family rewrite_segment_inner strips — ROUTABLE_WRAPPER_PREFIXES (7 entries), PROCESS_WRAPPERS (4), the env-assignment prefix, and an unbounded user-configured transparent_prefixes list. Patching the four I named would leave the config-driven one open, so please fix it at the boundary rather than by enumeration.
I am not sending a patch for this one, because I built the obvious one and it is wrong. Scanning every word start for a wrapper does close all twelve rows above (verified), but it also makes echo bash -c 'rm -rf /tmp/x' return Deny — a command that only mentions a wrapper. Two directions that do not have that failure mode, your call which:
- have the permission check consume the wrapper spans the rewriter actually used, rather than re-deriving them (this is what "one parser backs both" would mean literally, and it needs no config in
permissions.rs); or - thread
transparent_prefixesintocheck_command_with_rulesand reuserewrite_segment_inner's prefix-stripping beforeparse_shell_wrapper.
2. The branch does not build on current develop, and one conflict hunk silently reverts #3171 if resolved toward this PR.
develop has moved 304 commits since the 09-07 rebase; mergeStateStatus is DIRTY. Three conflict hunks in src/discover/registry.rs and one in src/hooks/README.md. The one that matters is the third: develop added the .or_else(|| rewrite_pipeline_producer(...)) fallback (#3171), and this branch's side of that hunk does not have it — taking "theirs" drops producer-stage rewriting with nothing failing to say so.
I resolved it and ran the gates so the rebase is transcription rather than discovery. Nine items, in order:
registry.rsimport block — takedevelop's ordering, addcontains_unattestable_constructanduse super::shell_wrapper::parse_shell_wrapper;.rewrite_pipeline_stage(new ondevelop) — addshell_wrapper_depth: usize, thread intorewrite_segment_inner.rewrite_pipeline_final_stage—developreshaped the signature; passshell_wrapper_depthtorewrite_pipeline_stage.rewrite_pipeline_producer— add the parameter and keepdevelop's.or_else(...)fallback.strip_process_wrapper_prefixcall site (#2375, ~registry.rs:1740) — a newrewrite_segment_innercall that needs the sixth argument. This is the one that fails to compile:error[E0061]: this function takes 6 arguments but 5 arguments were supplied.registry.rs:1373—if shell_wrapper_depth < MAX_SHELL_WRAPPER_DEPTH { if let Some(...) }must become a let-chain, or edition 2024 fails clippy:error: this 'if' statement can be collapsed.cargo fmt --all— twoassert!blocks inshell_wrapper.rsreflow under style edition 2024.- Four assertion strings:
rtk read foo→rtk read foo --head-lines 10(develop#3668 changed howheadrewrites). Inregistry.rs:7699,hook_cmd.rs:2859,hook_cmd.rs:2884,tests/rewrite_shell_wrapper_test.rs:22. Without this, 3 unit tests fail. src/hooks/README.md— keepdevelop'shook_cmd.rsline ("maps decisions to each agent's JSON protocol, including CodexupdatedInput"), then add thediscover/shell_wrapper.rsline.
After those nine: cargo fmt --all --check clean, cargo clippy --all-targets -- -D warnings clean, cargo test --all 3,843 passed, 0 failed. The wrapper matrix and develop's producer path (git log | head -20 → rtk git log | head -20) both still behave.
Optional — will not hold merge; I will resolve these threads myself
exclude_commandscannot exempt a wrapper. The wrapper branch sits above everyis_excludedcall inrewrite_segment_inner, so withexclude_commands = ["bash"],["bash -c"],["^bash"]or["bash -c .*"],bash -c 'git status'is still rewritten tobash -c 'rtk git status'(control:["git"]correctly suppresses it). A user who hits any sharp edge has no escape hatch.if is_excluded(command, excluded) { return None; }at the top ofrewrite_shell_wrappercovers it.is_shell_wrapper_candidatestops scanning at the first non--token, so any shell option taking a separate argument hides the-cbehind it.bash -lc,bash -e -candbash --norc … -ccorrectly forceAsk;bash -o pipefail -c,bash -O extglob -candbash --rcfile F -creachAllow. Not a regression —developallows all of them — but it is the same claim as blocking item 1, so worth closing in the same pass.registry.rs:1322— the doc comment sayssh|bash|zsh|fish -c;SUPPORTED_SHELLSdeliberately omits fish and a test pins that. Drop|fish.- The commit message says
Closes #2767while the PR body says "it does not close it" (fish is out of scope). The trailer wins whendevelopreaches the default branch. SuggestRefs #2767.
Checked and correct — no need to re-verify
- Byte preservation: shell path, quote delimiters, tabs/spacing, suffix arguments and fd redirects all come back identical (
sh\t-c\t'…'\tname,/bin/bash -c '…' command-name 'a b',… 2>&1). - Inner deny across
;,&&,||,|,&, both quote styles, andsh/zsh//bin/bash— all 10 bare forms returnDeny. - All six agent entry points end to end with real payloads, not test helpers:
claude,cursor,gemini(ask_user+ rewrittentool_input),copilot,droid,vibe. (codexis not artk hooksubcommand.) - Recursion cap:
bash -c 'git status && bash -c "cargo test"'→bash -c 'rtk git status && bash -c "cargo test"';bash -c 'bash -c "git status"'→ passthrough. sudo,sudo -E,sudo -u nobody,doasall pass through.- Your gates on the head:
cargo fmt --all --checkclean,cargo clippy --all-targets -- -D warningsclean,cargo test --all3,243 passed / 0 failed.
Hypotheses built and dropped
- An inner rewrite emitting a shell metacharacter that changes the wrapper's meaning. 321 real commands harvested from the repo's rewrite tests → 611 single- and double-quoted wrapped cases → 379 rewritten. Zero introduced
',",$,`or\; nortk_cmdinRULEScontains one. Every inner script also matched the bare rewrite of the same command. - UTF-8 span safety. Every boundary (
script_start,script_end,supported_shell_end) lands on an ASCII',", space or tab, which cannot occur inside a multibyte sequence, andreplace_scriptuses.get()throughout. Your truncation corpus test already covers it. - Pipeline-final guards bypassed inside a wrapper. The bypass is real —
developrefusescat pat.txt | grep -f - data.txt(exit 1, the-f/--filedeferral in TECHNICAL.md), and this PR rewrites the same thing insidebash -cbecauserewrite_shell_wrapperre-enters throughrewrite_compound, which resetsRewriteContexttoNormal. But I could not turn it into a defect:grep -f -,jq .,wc -landgrep bas wrapped final stages all produced byte-identical stdout and exit codes raw vs rewritten. Recording it as a lost safety margin, not a finding — if you want to restore it, skipping the wrapper branch whencontext == RewriteContext::PipelineFinalis the one-line version. Allow→Askfor wrappers a user had explicitly allowlisted. Real but deliberate and documented, and it only reaches a host when RTK actually rewrote; the hooks emit nothing on a non-rewrite, so nobody gets a new prompt for a wrapper RTK left alone.
Your questions
- "Only
license/clahas run on this PR — no build or test check has been triggered. Anything needed from me to get CI to run?" → Nothing from you. This is a fork PR, so theCIworkflow parks ataction_requireduntil a maintainer approves the run — run34109043724on7518b894, and three earlier ones on older heads, are all sitting in that state. I'll approve the run on the rebased head; approving it on this one would only fail at checkout while the PR conflicts. - "#3057 is still open and untouched since July; if this lands, that one should close with it. Happy to defer entirely if @pashifika would rather refresh theirs." → No need to defer. #3057 is already closed, and #3911 is explicitly stacked on this branch, so this is the one that lands first. Keep the
Co-authored-by.
Next
Fix blocking item 1 at the boundary (your call which of the two directions), then rebase with the nine steps above — I have those verified green, so they should not cost you a debugging pass. The four Optional items are cheap enough to fold into the same push. Then ping me and I'll approve the CI run.
Rounds: 1/3. Threads: 2 open (blocking items 1 and 2), 0 resolved this round.
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). `exclude_commands` exempts a wrapper the same way it exempts any other command. 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 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. The gate does not look for that boundary itself. The rewrite walk records the wrapper scripts it enters, and `scan_shell_wrappers` replays that walk so `check_with_wrapper_scan` can judge exactly those. The two therefore cannot disagree about where a wrapper may begin — which matters because the rewriter reaches one behind an env assignment, a shell keyword, a process wrapper (rtk-ai#2375) or any entry in the user's own unbounded `transparent_prefixes`, while enumerating those in the gate would leave the config-driven ones open. A word that merely looks like a wrapper is never such a position, so `echo bash -c '...'` stays untouched. Refs rtk-ai#2767 Co-authored-by: pashifika <pashifika.mkii@gmail.com>
7518b89 to
07e17ba
Compare
|
Round 1 addressed. Head is Blocking 1 — the boundaryTook your first direction: the gate consumes the spans the rewriter used rather than re-deriving them.
Same settings as your matrix ( That last row is the reason this shape rather than a word-start scan: End to end, same settings: Pinned by One shape note: the scan is Blocking 2 — the rebaseTranscribed your nine steps; all nine were exactly as described, including the Thank you for the recipe; it cost no debugging pass. Optional — all four folded in
GatesLinux x86_64 (NixOS). Ready for the CI run whenever suits you. |
KuSh
left a comment
There was a problem hiding this comment.
Round 2 of 3 — CHANGES REQUESTED
Claim: rewrite the inner commands of an exact quoted sh|bash|zsh -c wrapper, byte-preserving the wrapper itself, without letting a host allow rule approve a script the host never parsed.
Scope: accept (round 1, frozen).
Ran: develop @2bf3eb76 and head @07e17bab built into separate CARGO_TARGET_DIRs (md5 distinct). Both round-1 blockers' repros re-executed, plus the full round-1 matrix: 13 prefix families × inner-deny, 8 unsupported-option spellings × compound, all 6 agent hooks each against its own rule source, a 7,200-shape byte-preservation fuzz (4,692 rewritten), under-the-hook execution (rewrite, then run what it returns, diffed against raw), and two mutations of the new invariants. Invariants: every arm of the new decision driven — inner Deny, unparsed→Ask, Allow→Ask downgrade, excluded→no surface, non-wrapper→untouched — plus the pipeline positions, which round 1 did not drive and which is where both new blockers live. No filter output changes, so the 20% floor does not apply.
Matrix: executed round 1 @7518b894; head moved, so re-executed in full this round @07e17bab.
CI @07e17bab: run 35629235291 still action_required. Deliberately not approving it this round — the fix below moves the head, and a run on a head that is about to be replaced tells neither of us anything. I will approve the run on your next push.
Both round-1 blockers are fixed. Verified by execution, not by reading.
1. The boundary. Taking the scan off the rewrite walk instead of re-deriving it is the right call, and it is what makes the config-driven prefixes correct by construction. I re-ran the round-1 table and added devenv shell --, a second config-driven prefix that was in neither of our tables:
develop @7518b894 @07e17bab
bash -c 'git status; rm -rf /tmp/x' 1 2 2
command / env / exec / builtin / noglob bash -c … 1 0 2
uv run / FOO=bar / nice / nohup / timeout 30 … -c … 1 0 2
direnv exec . bash -c 'git status; rm -rf /tmp/x' 1 0 2
devenv shell -- bash -c 'git status; rm -rf /tmp/x' 1 0 2 ← added
echo bash -c 'rm -rf /tmp/x' 1 1 1
2. The rebase. Merges clean onto develop @2bf3eb76, nine commits past your base; .or_else(|| rewrite_pipeline_producer(...)) intact. Gates on the merged tree: fmt clean, clippy clean, cargo test --all 3,975 passed, 0 failed.
All four Optional items are folded in and verified, including that the widened option scan does not over-fire (bash -o pipefail script.sh is still not a wrapper).
Blocks merge (1 class, 2 faces — new in this round)
src/discover/registry.rs:1805 — the wrapper hook is spliced into rewrite_segment_inner above the pipeline machinery, so it neither inherits the pipeline context nor participates in the pipeline's branch order. One cause, two faces. Both are in RewriteContext/pipeline territory, which round 1 never drove — see the note at the end, this is my miss.
Face A — PipelineSafety is bypassed inside a wrapper, and a downstream consumer gets compressed bytes. rewrite_shell_wrapper re-enters through rewrite_compound, which restarts at RewriteContext::Normal, so every PipelineSafety::None rule becomes rewritable again as soon as it is wrapped:
develop @07e17bab
curl https://example.com | cat (unchanged) (unchanged)
bash -c 'curl https://example.com'|cat(unchanged) → bash -c 'rtk curl …' | cat
gh pr list | cat (unchanged) (unchanged)
bash -c 'gh pr list' | cat (unchanged) → bash -c 'rtk gh pr list' | cat
bash -c 'git log' | cat (unchanged) → bash -c 'rtk git log' | cat
echo x | bash -c 'git log' (unchanged) → echo x | bash -c 'rtk git log'
Executed, real repo, what cat actually receives:
$ bash -c 'git log' | cat # develop leaves this alone
commit 925c44f6e99e7664e6e459a510208eb924199108
Author: t <a@b>
Date: Fri Sep 25 00:54:05 2026 +0200
commit number 8 with a reasonably long subject line
… [47 lines, 1287 bytes]
$ bash -c 'rtk git log' | cat # what the hook now hands the agent
925c44f commit number 8 with a reasonably long subject line (il y a 0 seconde…
… [8 lines, 648 bytes]
Full hashes, author and date are gone. That is the corruption PipelineSafety was added for (#3171), and it is a regression against develop, which refuses all six commands above.
Face B — the inner-deny gate is unreachable whenever the pipeline's final stage is itself rewritable. rewrite_pipeline_final_stage(...).or_else(|| rewrite_pipeline_producer(...)) short-circuits, so when the final stage rewrites, the producer is never walked and a wrapper sitting there is never recorded on the scan. Appending a final-safe consumer is enough to turn the deny off:
# deny = ["Bash(rm:*)"], allow = ["Bash(*)"] develop @07e17bab
bash -c 'rm -rf /tmp/x' | cat 1 2 ← Deny, correct
bash -c 'rm -rf /tmp/x' | tail -5 1 2 ← Deny, correct
bash -c 'rm -rf /tmp/x' | head -5 1 2 ← Deny, correct
bash -c 'rm -rf /tmp/x' | grep foo 0 0 ← Allow
bash -c 'rm -rf /tmp/x' | rg foo 0 0 ← Allow
bash -c 'git status; rm -rf /tmp/x' | grep foo 0 0 ← Allow
command bash -c 'rm -rf /tmp/x' | grep foo 0 0 ← Allow
direnv exec . bash -c 'rm -rf /tmp/x' | grep foo 0 0 ← Allow
| cat denies and | grep foo auto-approves the identical inner rm -rf. develop allowed both, so this is not a regression — but it is the gate's own Deny arm being unreachable for a whole shape, which test_shell_wrapper_inner_deny_wins cannot see because it never puts the wrapper in a pipeline.
On the fix — I built one and it only covers face A, so I am not sending it as a patch. Scanning in any context but rewriting only in RewriteContext::Normal is about eight lines, and I measured it: all six face-A commands return to develop's passthrough, bash -c 'rm -rf /tmp/x' | cat still denies, plain and prefixed wrappers still rewrite (3/2 unchanged), and git log | head -20 / ls -la | grep foo are untouched. But face B is unchanged by it (| grep foo still 0), because the short-circuit is in the branch order, not in the rewrite decision — the scan has to walk the producer branch even when the final stage already succeeded. Your call whether that is one change or two; if you would rather I push the face-A half as a fixup while you take face B, say so.
Checked and correct — no need to re-verify
- Byte preservation: 7,200 shapes (12 prefixes × 5 shells × 2 quote styles × 10 scripts × 6 pipeline positions), 4,692 rewritten, 0 anomalies — quote counts, prefix, shell token and
-cpreserved; nothing introduced into a single-quoted script; no stderr; no exit code outside {0,1,2,3}. - All six agent hooks, each against its own rule source —
claude/copilot/droid/vibevia.claude/settings.json,cursorvia.cursor/cli-config.json,gemini. Deny → passthrough, wrapper rewrite → ask +updatedInput, neverpermissionDecision: allow. - Under the hook: rewrite, then execute what it returns, against a real repo — six wrapper forms, exit codes identical to raw, differences are RTK's own compression.
- Mutation testing. Making the inner-deny loop unable to fire fails three named tests; removing the
Allow→Askdowngrade fails seven acrosspermissionsand all four host protocols. Both new properties are genuinely pinned. - Cost:
hyperfine -N6.7 ms ± 0.5 on the wrapper path, identical to an ordinarygit statusrewrite and inside the 10 ms target.hook_rewrite_params()goes through the process-widecached_config(), socheck_command_foradds one load per process, anddiscovercorrectly takes the_precompiledform. - Recursion cap,
sudo/sudo -E/doaspassthrough, and theRefCellsites (two statement temporaries, no borrow live across the recursive call; no panic in 7,200 cases).
Hypotheses built and dropped
- A phantom script recorded from the abandoned
.or_elsebranch. The direction exists — a final-stage wrapper that parses and then fails to rewrite stays recorded — but every recorded script is a real substring of a command that really runs, so there is no phantom. Recording on the parse is deliberate and can only make the verdict stricter. - Cursor, Droid, Gemini and Vibe emitting a rewrite for a denied command. Reproduced, then traced to my own harness: those hosts read their own rule files, not
.claude/settings.json. Re-run with{"deny":["Shell(rm)"],"allow":["Shell(*)"]}in.cursor/cli-config.json, all three denied forms pass through correctly. Not a defect. - Inner deny at nesting depth 2 (
bash -c 'git status && bash -c "rm -rf …"'). The nested script is not recorded, so the deny does not reach it; the verdict isAskwheredevelopreturnsAllow, so the new path is stricter than what it replaces. Not worth a second level — noting it so it is not re-derived. - The
Allow→Askcap not reaching the host when RTK produces no rewrite (bash -lc 'git status'→Defer). Real, but identical todevelop, and the cap does bite in every case where RTK speaks at all —bash -lc 'git status' && cargo testisAskon head againstAllowondevelop, for all eight option spellings. The README's "its strongest RTK verdict isAsk" is accurate; only the test's name is broader than what it pins.
Your questions
- "Ready for the CI run whenever suits you." → Holding it until your next push, for the reason in the CI line above. Nothing is needed from you to make it runnable; it is mine to approve and I will.
- The
&RefCell<WrapperScan>in aCopyWrapperWalkfor the two-branch call site → Right call, and keepingdevelop's.or_elseshape rather than reshaping it for a&mutis the correct trade. I probed the failure mode that choice invites and it holds. Face B is the order of those two branches, not the sharing.
Next
Decide whether the wrapper may be entered at all outside RewriteContext::Normal (face A), and make the scan walk the producer branch even when the final stage already rewrote (face B). Everything else on this PR is verified and done — the blocking set is this one class and nothing more.
One thing I owe you: I raised this exact class at round 1 and dropped it. I had probed grep -f -, jq ., wc -l and grep b, which all happen to be near-identity filters, concluded "bypass real, breakage not reproducible", and moved on. curl, gh, docker and git log are the same class and are not near-identity, and I did not try them. Round 1 should have caught this and the second round is on me, not on you.
Rounds: 2/3. Threads: 1 open (the class above), 2 resolved this round (both round-1 blockers), 4 optional resolved.
| // (`direnv exec . bash -c '…'`) is still seen, and the permission gate reads | ||
| // the same positions back off `wrappers.scan`. The wrapper depth is the only | ||
| // recursion bound that matters here. | ||
| if wrappers.may_enter() |
There was a problem hiding this comment.
Blocks merge — face A of 1 class. The wrapper hook sits here, above the RewriteContext checks further down, and rewrite_shell_wrapper re-enters through rewrite_compound, which restarts the inner walk at RewriteContext::Normal. Every PipelineSafety::None rule therefore becomes rewritable again as soon as it is inside bash -c, and a downstream consumer receives rtk-compressed bytes:
develop @07e17bab
curl https://example.com | cat (unchanged) (unchanged)
bash -c 'curl https://example.com'|cat (unchanged) → bash -c 'rtk curl ...' | cat
gh pr list | cat (unchanged) (unchanged)
bash -c 'gh pr list' | cat (unchanged) → bash -c 'rtk gh pr list' | cat
bash -c 'git log' | cat (unchanged) → bash -c 'rtk git log' | cat
echo x | bash -c 'git log' (unchanged) → echo x | bash -c 'rtk git log'
Executed against a real repo, what cat actually receives:
$ bash -c 'git log' | cat # develop leaves this alone
commit 925c44f6e99e7664e6e459a510208eb924199108
Author: t <a@b>
Date: Fri Sep 25 00:54:05 2026 +0200
commit number 8 with a reasonably long subject line
... [47 lines, 1287 bytes]
$ bash -c 'rtk git log' | cat # what the hook now hands the agent
925c44f commit number 8 with a reasonably long subject line (il y a 0 seconde...
... [8 lines, 648 bytes]
Full hashes, author and date gone — the corruption PipelineSafety was added for (#3171), and a regression against develop, which refuses all six commands above.
I prototyped the obvious fix — scan in any context, rewrite only when context == RewriteContext::Normal — and measured it: all six commands return to develop's passthrough, bash -c 'rm -rf /tmp/x' | cat still denies, plain and prefixed wrappers are unchanged, and git log | head -20 / ls -la | grep foo are untouched. It does not fix face B (see the other comment), so I am offering it as a direction rather than a patch. Happy to push just this half as a fixup if you would rather split it.
| analysis, | ||
| excluded, | ||
| transparent_prefixes, | ||
| wrappers, |
There was a problem hiding this comment.
Blocks merge — face B of the same class. This .or_else short-circuits: when rewrite_pipeline_final_stage succeeds, rewrite_pipeline_producer is never called, so a wrapper sitting in the producer position is never walked and never recorded on the scan. The inner-deny gate is then unreachable, and appending any final-safe consumer is enough to switch it off:
# deny = ["Bash(rm:*)"], allow = ["Bash(*)"] develop @07e17bab
bash -c 'rm -rf /tmp/x' | cat 1 2 <- Deny, correct
bash -c 'rm -rf /tmp/x' | tail -5 1 2 <- Deny, correct
bash -c 'rm -rf /tmp/x' | head -5 1 2 <- Deny, correct
bash -c 'rm -rf /tmp/x' | grep foo 0 0 <- Allow
bash -c 'rm -rf /tmp/x' | rg foo 0 0 <- Allow
bash -c 'git status; rm -rf /tmp/x' | grep foo 0 0 <- Allow
command bash -c 'rm -rf /tmp/x' | grep foo 0 0 <- Allow
direnv exec . bash -c 'rm -rf /tmp/x' | grep foo 0 0 <- Allow
| cat denies and | grep foo auto-approves the identical inner rm -rf. develop allowed both, so this is not a regression — but it is the gate's own Deny arm being unreachable for a whole shape, which test_shell_wrapper_inner_deny_wins cannot see because it never puts the wrapper in a pipeline.
The scan needs to walk the producer branch even when the final stage already rewrote. My face-A prototype does not do this — I verified | grep foo still returns 0 under it — so this is the half that genuinely needs your call on shape.
|
@SindriFr are you available and willing to work on requested changes on your PR? |
|
This landed in Verified on the merged result, before → after: Six of this PR's seven test cases behave on Two defects were found and fixed on top of it while the stack was in review, both in code this PR
Closing as landed rather than merged, since the branch here has been |
Summary
Rewrites the portable commands inside an exact quoted
sh -c,bash -c, orzsh -cwrapper, leaving the wrapper's own bytes untouched.Agents that reach a project toolchain through a wrapper (
direnv exec . bash -c '…',devenv shell -- bash -c '…', container and CI runners) send every command as a quoted script, so the whole invocation was opaque and no inner command was rewritten.shell_wrapper.rsand most tests are @pashifika's, from #3057, rebuilt against currentdevelop— the rewrite engine has since gainedRewriteContext,PipeKind,analyze_pipeline,pipeline_final_safe, multi-line block handling,decision.rsand the #3171 producer fallback. Credited viaCo-authored-by.Refs #2767. That issue also asks for
fish -c, which this deliberately leaves out (see Passthrough cases), so it does not close it.Eligibility
Deliberately narrow. The shell must be exactly
sh,bash, orzsh(any path, e.g./bin/bash), the option exactly-c, and the script one balanced single- or double-quoted argument.Only the script's byte span is replaced, so shell path, quote delimiters, horizontal spacing, suffix arguments and fd redirects come back byte-identical:
A wrapper is found at any prefix depth, so one behind a transparent prefix, a strippable
command, or a process wrapper is reached too:Passthrough cases
These return to the host unchanged:
sudo— the innerrtkwould run as root, where it is offsecure_path, the same reason a baresudo docker psis left alone (Feature Request: Add sudo prefix support for command rewriting #146). Pinned bytest_shell_wrapper_behind_sudo_is_not_rewritten.exclude_commandsmatches —exclude_commands = ["bash"]exempts a wrapper the same way it exempts any other command, and exempts it from the permission surface too$(($expansion, backticks, or backslash escapes in a double-quoted scriptcontains_unattestable_constructrejects — command/process substitution, file-target redirectsbash -lc,bash -e -c,bash -o pipefail -c,fish --commandfish, whose control keywords (and,or,if) at a command boundary need a fish-aware lexer the shared one does not modelPermission handling
A host's allow rule matched the outer command — it never parsed the quoted script. Rewriting inside that script therefore must not widen what the rule approved, so:
Ask, so a*allow rule cannot approve a script the host never sawAsk-capable hosts prompt, others keep their native permission flowThe gate does not look for the wrapper itself.
rewrite_segment_innerprobes for one at every prefix-stripping level, and records each script it enters on aWrapperScan.registry::scan_shell_wrappersreplays that same walk and hands the result topermissions::check_with_wrapper_scan, which judges exactly those scripts. So the gate and the rewriter cannot disagree about where a wrapper may begin — behind an env assignment, a shell keyword, a routable or process wrapper (#2375), or any entry in the user's own unboundedtransparent_prefixes, both reach it or neither does. A word that merely looks like a wrapper is never such a position, soecho bash -c 'rm -rf /tmp/x'is untouched.A command-string wrapper the strict parser refuses (
bash -lc,bash -o pipefail -c) is never rewritten, but it is recorded too: the host did not decompose that script either, so it must not ride an outer allow.Agent protocol coverage: Claude, Cursor, Gemini, Copilot CLI, Droid.
Test plan
cargo fmt --all --checkcargo clippy --all-targets -- -D warningscargo test --all— 3,958 passed, 0 failed, 8 ignored, all suites greentests/rewrite_shell_wrapper_test.rsrtk rewriteandrtk hook claudeverified end-to-end against an isolatedHOME/XDG_CONFIG_HOMEwith real.claude/settings.jsonandrtk/config.toml, over every prefix family in the round-1 matrixTested on Linux x86_64 (NixOS).