Skip to content

fix(rewrite): split multi-line blocks on a lone CR separator - #3600

Closed
breisnerlopez wants to merge 2 commits into
rtk-ai:developfrom
breisnerlopez:fix/rewrite-lone-cr-separator
Closed

breisnerlopez wants to merge 2 commits into
rtk-ai:developfrom
breisnerlopez:fix/rewrite-lone-cr-separator

Conversation

@breisnerlopez

Copy link
Copy Markdown
Contributor

Focused follow-up to #3274, taking @KuSh's suggested route: close the broad PR (now superseded by fc8054e) and cover just the one gap that commit left open — a lone \r separator (no \n) — using its safer per-line, guarded approach.

The gap

fc8054e rewrites each line of a multi-line Bash block, but the dispatch in rewrite_command only enters that path on '\n':

if trimmed.contains('\n') {
    return rewrite_multiline_block(...);
}

A block whose only separator is a bare \r (classic-Mac line ending, no trailing \n) falls through to rewrite_single: the rtk prefix lands on the first line and every following line runs raw and unfiltered — exactly the bug fc8054e fixed for \n, still open for lone \r.

The fix

The quote-aware lexer already tokenizes a lone \r as a newline operator, so widening the gate to trimmed.contains(['\n', '\r']) routes these blocks through rewrite_multiline_block unchanged. All of its existing guards still apply (unsafe lines, cross-line continuations, quote-swallowed breaks), so a \r inside quotes stays one logical command.

Why it's safe

It never over-splits relative to gating: split_for_permissions already tokenizes lone \r as a boundary, so the permission layer was already evaluating each post-CR line independently — the rewriter was the only side lagging. Widening it only makes the two agree.

Tests

  • test_lone_cr_separators_rewrites_each_line — git status\rgit log → both prefixed.
  • test_lone_cr_inside_quotes_rewrites_as_one_command — CR in quotes stays one command.
  • test_split_perms_lone_cr / ..._inside_quotes_not_split — permission layer splits a dangerous post-CR command on its own, and never splits a quoted CR.

cargo fmt --check, clippy, and the full suite (2616) are green.

@KuSh

KuSh commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Reviewed this and I think the premise doesn't hold, and merging as-is would make things worse rather than better.

The PR widens rewrite_command's multiline gate to also split on a lone \r (no \n), reasoning that split_for_permissions already treats a bare \r as a command boundary, so the rewriter should "agree" with it.

The problem: bash does not split on a bare \r. I verified directly:

$ bash -c $'rtk git status\rrtk git log --oneline -3'

does not run as two commands — \r is treated as an ordinary character inside a word, producing one garbled invocation. So split_for_permissions's lone-\r boundary was already a misclassification; this PR just extends the same misclassification to the rewriter.

Concretely, for git status\rgit log --oneline -3 this now produces:

rtk git status\rrtk git log --oneline -3

(rewrite_multiline_block reconstructing the raw \r as the joiner, registry.rs:889-890). That string is what gets executed by the shell (src/hooks/rewrite_cmd.rs:24-27, src/hooks/hook_cmd.rs:266-269) — as a single mangled command — while split_for_permissions believes it evaluated and approved two independent commands. Before this change the rewriter's output at least stayed closer to what the user actually typed; after it, the permission layer and the actual executed command diverge.

The new test test_lone_cr_separators_rewrites_each_line asserts the buggy output as correct, so it doesn't catch this.

I'd suggest starting from the other end: confirm what bash actually does with a lone \r (nothing — it's not a separator), and either stop split_for_permissions from treating it as a boundary, or drop this widening. Happy to be wrong if I'm missing context on why lone-\r splitting is intentional here.

A bare `\r` (classic-Mac EOL, no trailing `\n`) is not a command boundary:
bash runs `git status\rgit log` as a single command (the CR is an ordinary
character in a word). The earlier approach split on it, so the rewriter emitted
two `rtk` prefixes joined by a raw CR — a mangled single command that diverged
from what the permission layer had approved.

- lexer: tokenize a newline operator for `\n` and for the `\r` of a CRLF pair,
  but not for a lone `\r`, which falls through to whitespace. CRLF tokenization
  is unchanged, so rewrite_multiline_block still preserves the `\r\n` bytes.
- rewrite_command dispatches on `\n` alone again; a lone-CR block is prefixed
  once, matching the one segment split_for_permissions now produces.
- raw_breaks counts `\n` plus the `\r` of a CRLF, not a lone `\r`.

Supersedes the split-on-CR approach (per review: bash doesn't split on a lone CR).
@breisnerlopez
breisnerlopez force-pushed the fix/rewrite-lone-cr-separator branch from 46e5de5 to 073f96b Compare August 20, 2026 11:25
@breisnerlopez

Copy link
Copy Markdown
Contributor Author

You were right — bash doesn't split on a lone \r, so split_for_permissions treating it as a boundary was the actual bug, and widening the rewriter to match just propagated it. Flipped the fix to your suggestion: a lone CR is no longer a command boundary in either layer.

  • The lexer emits a newline operator for \n and for the \r of a CRLF pair (peek for the \n), but not for a lone \r — it falls through to whitespace, a word break at most. CRLF tokenization is unchanged, so rewrite_multiline_block keeps preserving the \r\n bytes.
  • rewrite_command dispatches on \n alone again; a lone-CR block is prefixed once, matching the single segment split_for_permissions now yields. The old two-prefixes-joined-by-a-raw-CR divergence is gone.
  • Verified against real shells: bash -c $'echo A\rls /x' runs one command (ls never executes), dash the same, and CRLF still splits. The CR→space normalization rtk already does only ever pushes an adversarial \r case toward Deny, never toward auto-Allow.

Force-pushed the pivot over the previous commit. fmt / clippy --all-targets -D warnings / suite all green.

Comment thread src/discover/lexer.rs Outdated
Comment thread src/discover/registry.rs
@KuSh

KuSh commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Took a closer look at the fix (073f96b) — the core change is correct (lone \r is no longer treated as a separator in the lexer, split_for_permissions, or rewrite_command, and I verified that matches actual bash behavior). But it exposed two adjacent spots that reason about command text and weren't updated to the same rule, both empirically confirmed on this branch:

1. src/hooks/permissions.rs:386 — command_matches_pattern normalizes with cmd.split_whitespace().join(" "), which still collapses a lone \r into a space. So with allow_rules = ["git status"] and cmd = "git status\rrm -rf ~":

  • split_for_permissions now correctly returns it as one segment (per this PR's semantics)
  • but command_matches_pattern normalizes that segment to "git status rm -rf ~", which starts_with("git status ") — so the allow-rule silently approves it
  • rewrite_command then emits "rtk git status\rrm -rf ~" with the raw CR preserved

The allow-rule ends up matching against text that was never what will actually execute — the same gate/execution divergence this PR set out to fix, just in the pattern matcher instead of the splitter.

2. src/discover/lexer.rs:284 — the generic is_whitespace() arm still treats a lone \r as a word boundary in tokenize_inner itself, so tokenize("git status\rgit log") yields 4 separate Arg tokens (["git","status","git","log"]) even though bash keeps status\rgit glued as one word. Anything doing token-position classification (subcommand routing, permission matching against tokens[1]) can see a "clean" token bash never actually produces standalone — a second, independent path feeding the same class of bug as #1.

Both look like they'd want the same "lone \r is not a boundary" treatment this PR already applied to the rewriter and split_for_permissions.

kylehgc added a commit to kylehgc/rtk that referenced this pull request Aug 31, 2026
… tickets filed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review: merge the \n and \r newline-operator arms in tokenize_inner
into one, and extract the CRLF detection into a pub(crate) is_crlf_at(bytes, i)
helper so the lexer and the registry multi-line parity check use a single
source of truth for the rule. Behavior-preserving.
@breisnerlopez

Copy link
Copy Markdown
Contributor Author

Both nits applied in 5912af1:

  • lexer.rs:274 — took your suggestion: the \n and \r newline-operator arms are now one arm, c @ ('\n' | '\r') if emit_newline && (c == '\n' || is_crlf_at(bytes, byte_pos)).
  • registry.rs:871 — extracted the CRLF-vs-lone-CR rule into a pub(crate) fn is_crlf_at(bytes: &[u8], i: usize) -> bool in lexer, and both call sites use it now: tokenize_inner (via input.as_bytes() + byte_pos, byte-identical to the old chars.peek() check) and the raw_breaks parity count in registry. Single source of truth for the rule.

Behavior-preserving — the CRLF / lone-\r tests (..._crlf_splits_like_newline, ..._lone_cr_is_not_a_boundary, _lone_cr_is_one_command_single_prefix) all still pass; fmt/clippy -D warnings/full suite green.

@KuSh

KuSh commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Apologies — I reviewed this PR in isolation across three rounds and never connected it to #3704, which is my own PR and covers the same ground. That's on me; the earlier feedback would have been much shorter had I spotted it. Now that I have, here's the comparison.

I built one probe harness and ran it identically at develop (4f3c9fe), this PR's head (5912af1), and #3704's head (0d1d9ed):

probe develop #3600 #3704
tokenize("git status\rgit log") [git,status,git,log] [git,status,git,log] [git,status\rgit,log]
tokenize("a\u{a0}b") (NBSP) [a,b] [a,b] [a\u{a0}b]
split_for_permissions("git status\rrm -rf ~") 2 segments 1 segment 2 segments
verdict, deny rule rm -rf * Deny Default Deny
verdict, allow rule git status* Default Allow Default
command_matches_pattern(…, "git status*") true true false
rewrite("git status\rgit log --oneline -3") rtk git status\rgit log… identical identical
rewrite("echo 'a\rb'\ngit log -3") (raw_breaks) None Some(…) Some(…)
rewrite("git status\r\ngit log -3") (CRLF) rtk …\r\nrtk … identical identical
rewrite("git status\rgit log\ngit diff") rtk git status\rrtk git log\n… rtk git status\rgit log\n… identical
shell_split("\"C:\\Program Files\\rtk.exe\"") C:Program Filesrtk.exe C:Program Filesrtk.exe C:\Program Files\rtk.exe

Every rewriter output is byte-identical between the two branches. #3704 already reproduces this PR's whole rewriter delta — the raw_breaks parity fix and the mixed-CR+LF single-prefix fix included.

The one behaviour unique to this PR is dropping the lone-CR boundary in split_for_permissions (row 3), and that is the regression: it turns Deny into Default and Default into Allow for git status\rrm -rf ~. #3704 deliberately keeps that boundary (NewlineMode::Conservative) while removing it from the rewriter, so the gate stays as conservative as develop while the bash-accurate rule applies everywhere else.

Why the gate should stay conservative: PowerShell reaches it too — hook_cmd.rs:127 accepts "powershell", and Copilot CLI remaps powershell → Bash (hook_cmd.rs:99-101). And in PowerShell a bare CR is a statement separator, verified in mcr.microsoft.com/powershell:latest:

$ pwsh -Command "$(printf 'Write-Output SAFE\rWrite-Output PWNED')"
SAFE
PWNED          <- two statements executed

$ bash -c $'echo SAFE\recho PWNED'
SAFE^Mecho PWNED   <- one command; "echo PWNED" never runs

So on a Windows Copilot/VS Code host, the Allow in row 5 auto-approves a string that really does execute rm -rf ~.

The tests here

Given all that I think the right move is to close this in favour of #3704 rather than rescope it — there's no remaining delta to rescope around. But it's your call, and the credit for finding the lone-CR problem in the first place is yours: #3704 exists because of the /code-review run on this PR, which is exactly what its description says.

KuSh added a commit that referenced this pull request Sep 4, 2026
Two follow-ups from /code-review high on this branch:

1. parse_golangci_run_parts's switch to the full shell tokenize() (Phase
   3) fixed the intended quoted-value bug but introduced a real
   regression: tokenize() splits unquoted shell metacharacters (*, ?,
   `, (, ), {, }, !) into their own tokens even outside quotes, so an
   unquoted glob value like `--config *.yml` desynced the flag-skip
   loop and got the whole command misclassified as Unsupported.
   split_token_spans's actual job - "was there a space here", not full
   shell syntax - is genuinely different from tokenize()'s, so it
   should not have been replaced by it. Restored split_token_spans as
   its own function, now built on the shared advance_quote_state /
   is_word_boundary_whitespace primitives instead of being quote-blind,
   which is what actually fixes the original quoted-value bug without
   the metacharacter regression. New regression test for the unquoted
   case.

2. Extracted is_crlf_at() and used it in both tokenize_inner's
   newline-operator guard and registry.rs's raw_breaks parity check -
   this exact duplication (two independent "is this \r part of a CRLF
   pair" checks) was flagged as a follow-up during the PR #3600 review
   that prompted this whole consolidation, and had been reintroduced
   here without actually being fixed.

All 2714 tests pass, clippy clean.
@breisnerlopez

Copy link
Copy Markdown
Contributor Author

Closing in favor of #3704, which already lands this PR's rewriter delta byte-for-byte (raw_breaks parity, the mixed CR+LF single-prefix fix, is_crlf_at, and NewlineMode). The only behavior unique to this PR — dropping the lone-CR boundary in split_for_permissions — is a security regression on hosts where a bare CR is a real statement separator (PowerShell, and Copilot CLI remapping powershell → Bash on Windows): git status + CR + rm -rf ~ would normalize to an auto-Allow. So develop's conservative boundary is the right call, and there's no salvageable delta left to rescope around. Thanks for the thorough probe-harness analysis — closing per your recommendation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants