Skip to content

fix(hook): split compound commands on newlines for rewriting - #3274

Closed
breisnerlopez wants to merge 2 commits into
rtk-ai:developfrom
breisnerlopez:fix/hook-rewrite-split-newline
Closed

breisnerlopez wants to merge 2 commits into
rtk-ai:developfrom
breisnerlopez:fix/hook-rewrite-split-newline

Conversation

@breisnerlopez

Copy link
Copy Markdown
Contributor

Problem

The hook rewriter splits a command line on &&, ||, ; and | before rewriting each clause, but not on a bare newline. So a multi-line command such as

cd /some/worktree
grep foo bar.txt

is treated as a single unrewritable segment and escapes rewriting entirely — grep/git/find/… on the following lines never become rtk grep/… This is common in cd <dir>-prefixed and worktree-oriented workflows, where it drops rtk coverage on those commands to ~0%.

Fix

  • Add tokenize_with_newlines (thin wrapper over the existing tokenize_inner(_, true)) and use it in rewrite_compound, so a bare \n is treated as a clause separator like ;. The \n is re-emitted verbatim in the rejoin (no surrounding spaces), preserving the original layout.
  • Include \n/\r in the has_compound fast-path so a command that starts with rtk but has more lines (rtk gain\nls -la) still falls through and rewrites the later lines.
  • Collapse \r\n/lone \r to a single \n separator so CRLF input doesn't emit a spurious blank line.

Safety

Command-substitution safety is unaffected: the permission/hook layer already defers on $(...)/backticks (and other unattestable constructs) before rewriting, and the allow-per-segment permission verdict already splits on newlines (the existing newline-bypass CVE guards). This change only touches the rewriter, which runs after and independently of the permission verdict — it can't elevate a decision or change how a dangerous command is treated. Verified end-to-end that git status\nrm -rf … is byte-identical to before (no auto-allow) and that multi-line $(...) defers with no rewrite.

Tests

New unit tests for: cd-prefixed newline, both-sides rewrite, newline+pipe, trailing newline, rtk-prefixed multi-line, CRLF collapse; plus a hook-level regression test that multi-line command substitution defers. cargo fmt --check and cargo clippy --all-targets clean; full suite green.

The hook rewriter split command lines on `&&`, `||`, `;` and `|` but not
on bare newlines, so a multi-line command such as `cd /worktree\ngrep foo`
was treated as a single unrewritable segment and escaped rewriting entirely
(0% rtk coverage for cd-prefixed / worktree workflows).

Tokenize with newlines enabled in `rewrite_compound` and re-emit `\n`
verbatim (like `;`). Also: include `\n`/`\r` in the `has_compound`
fast-path so a command that starts with `rtk` still rewrites later lines,
and collapse CRLF to a single LF separator.

Command-substitution safety is unaffected: the hook/permission layer defers
on `$(...)`/backticks before rewriting (regression test added), and the
allow-per-segment permission verdict already splits on newlines.
@breisnerlopez

Copy link
Copy Markdown
Contributor Author

This is the highest-impact of my open PRs on the token side: without it, compound commands split across newlines (cd …⏎grep … on separate lines) skip rewriting entirely, so their output streams through unfiltered — a big chunk of savings lost in parallel/worktree flows. CI's green and it's mergeable. Would appreciate a review when you have a moment, @aeppling 🙏

@breisnerlopez

Copy link
Copy Markdown
Contributor Author

Friendly nudge — this one's been open ~10 days with green CI and CLA signed. @aeppling @KuSh, when you have a spare cycle, would you mind taking a look?

It's the highest token-impact of my open PRs: without it, compound commands split across newlines (cd …⏎grep …) skip rewriting entirely and stream their output raw, so rtk saves nothing on them. The fix is scoped to the lexer/rewriter (newline splitting), keeps the substitution/permission guards untouched, and adds 7 tests.

For batch review, my other single-focus PRs on develop are: #3265 (git-show blob filter), #3266 (tee slug collisions), #3258 / #3259 (grep flag collisions), #3275 (pnpm global flags). No rush on any — happy to rebase or split further if that helps. Thanks for the project!

Comment thread src/discover/registry.rs Outdated
Comment on lines +708 to +713
let normalized: Cow<str> = if cmd.contains('\r') {
Cow::Owned(cmd.replace("\r\n", "\n").replace('\r', "\n"))
} else {
Cow::Borrowed(cmd)
};
let cmd = normalized.as_ref();

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.

This seems a bit too broad and could replace content inside quoted arguments, for example grep 'a\rb' file.txt with a raw CR byte inside the single quotes.

Suggestion: collapse CR/CRLF only on the newline tokens the tokenizer already isolates, since it already skips quoted content correctly.

It would also be worth adding a test for that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thank you — fixed exactly as you suggested in 33d4823. Instead of the global replace(), the collapse now happens on the newline token the tokenizer already isolates: tokenize_inner consumes a \r\n pair (and a lone \r) as a single Operator("\n") token, so CRLF still yields exactly one separator with no spurious blank clause, and a CR/LF inside quotes is never touched — it's consumed by the quote branch before the newline arm is ever reached. The global normalization and its now-orphan Cow import are gone.

Added the test you asked for plus a couple more:

  • test_rewrite_compound_cr_inside_quotes_not_a_separator — raw CR and CRLF inside single/double quotes stay a single rewritten segment.
  • On the shared-tokenizer / permission-gating side: test_split_perms_crlf, test_split_perms_cr_inside_quotes_not_split, and a CRLF case in test_attestable_subshell_and_separators, confirming a command hidden after a CRLF is still split into its own segment while a quoted CR can't smuggle one past the per-segment check.

cargo fmt --check + clippy --all-targets clean, full suite green (2511/0). CI's rerunning on the push.

Review feedback on rtk-ai#3274: rewrite_compound normalized CRLF/lone-CR to LF
across the whole command before tokenizing, which also rewrote a raw CR
*inside* quoted arguments (e.g. `grep 'a\rb' file.txt`) into a clause
separator, corrupting them.

Handle it where the tokenizer already isolates newlines instead:
tokenize_with_newlines now consumes a `\r\n` pair (and a lone `\r`) as a
single newline Operator token, so CRLF still yields exactly one separator
with no spurious blank clause, while a CR/LF inside quotes is left untouched
(it never reaches the newline arm). Drop the global replace and the now-unused
Cow import.

Tests: raw CR / CRLF inside quotes stays one segment in both rewrite_compound
and the permission layer (split_for_permissions, contains_unattestable_construct).

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

Thanks, LGTM!

@KuSh

KuSh commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Hi @breisnerlopez, unfortunately fc8054e already solves the exact same problem your PR targets.

The only thing your PR still adds is support for bare \r without \n, which is a narrow edge case.

You have two options:

  1. Rebase onto current develop and diff your intended behavior against rewrite_multiline_block.
  2. Close this PR and open a small follow-up just for the lone-\r separator gap, if you think it’s worth covering, using fc8054e’s safer per-line, guarded approach.

Sorry I didn’t catch that sooner

@breisnerlopez

Copy link
Copy Markdown
Contributor Author

Thanks @KuSh — makes sense, fc8054e covers the main case cleanly and I hadn't seen it land. Going with option 2: closing this in favor of a small, focused follow-up for just the lone-\r separator gap, built on fc8054e's per-line guarded path (rewrite_multiline_block) rather than my broader rewrite: #3600. Appreciate the pointer.

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