Skip to content

Preserve pipeline input while rewriting safe shell commands - #2555

Closed
ahundt wants to merge 6 commits into
rtk-ai:developfrom
ahundt:v3/lexer-command-analysis-superset
Closed

ahundt wants to merge 6 commits into
rtk-ai:developfrom
ahundt:v3/lexer-command-analysis-superset

Conversation

@ahundt

@ahundt ahundt commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Preserve pipeline input while rewriting safe shell commands

Summary

RTK now lexes shell command structure before rewriting it. Filters and other content consumers receive the producer's original output, while display-only tails can still use RTK's compact output. Redirections and shell-block boundaries remain attached to the command that owns them.

This fixes issue #1560, where rewriting the left side of a pipe changed the input seen by downstream filters. The lexer consolidates the boundary and suffix behavior developed in PR #536 and supports long, multi-line shell blocks without treating their internal operators as independent top-level commands.

Before and after

Command Before After
git log | grep feat RTK could replace the producer before grep read it. The command stays raw, so grep receives the original Git stream.
cargo test | tee /tmp/test.log The file could contain RTK's compact display instead of the test stream. The command stays raw, so tee saves the original output.
git log | head -20 Conservative pipe handling could leave a safe display tail unfiltered. RTK can rewrite the producer because head only consumes a bounded display prefix.
git status > /tmp/status.log The file-target redirect prevented the supported rewrite path. RTK preserves the suffix and returns an ask-only rewrite: rtk git status > /tmp/status.log.
git status $(whoami) Shell substitution could be mistaken for a safe rewrite boundary. The command stays raw because its execution boundary is opaque.
`if git status; then git log grep feat; fi` A text-only split could analyze the nested pipe as an independent top-level command.

Implementation

src/discover/lexer.rs tokenizes quotes, escapes, comments, CRLF/newline boundaries, pipes, redirects, case alternatives, and shell blocks using typed tokens and byte offsets. src/discover/registry.rs applies consumer-aware producer routing. src/discover/suffix.rs preserves the exact trailing output suffix and assigns an ask-only policy to file-target redirects.

The policy path is compatible with the multiline rewrite work in PR #2781: each statement is analyzed independently, and auto-allow requires both an attestable command and a rewrite that does not require confirmation. This also preserves the least-privilege behavior tracked in issue #1155.

Content consumers including grep, rg, tee, xargs, wc, awk, and sed remain raw. Safe display tails are limited to supported stdin-only numeric head and tail forms, plain cat, and equivalent absolute paths. Input redirects, substitutions, heredocs, and opaque grouped commands remain conservative.

Compatibility and scope

The existing string rewrite API remains available. Hook adapters use the policy-returning path when a file redirect requires confirmation. This change adds no parser dependency and does not execute native commands through the lexer.

Verification

  • 109 lexer tests cover quoting, escaping, comments, CRLF, pipes, redirects, suffixes, shell blocks, content consumers, safe tails, and conservative fallbacks.
  • The full branch suite passes with 2,570 tests passed and 8 ignored.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --all -- --check, and git diff --check pass.

@CLAassistant

CLAassistant commented Jun 24, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@ahundt ahundt changed the title Improve command rewrite analysis for pipes and redirects Improve command rewrite success rate for pipes and redirects (lexer) Jun 24, 2026
@aeppling aeppling self-assigned this Jun 24, 2026
@aeppling aeppling mentioned this pull request Jul 2, 2026
@reneleonhardt

Copy link
Copy Markdown

Thank you so much for your important fix! ❤️

In case you want to update, git rebase master resolved automatically, that warning seems strange to me in case "Changes can be cleanly merged." is shown too 🤷

@aeppling

aeppling commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Hey @ahundt, thanks for this contribution.

I'm currently working on the rewrite, since models now often use multi-line statements: #2781
It would be nice to ship both in the same release.

If you're available to align with my PR, I can merge mine and you adjust this one afterwards; otherwise I can try to retrieve your fixes into mine. Your call

If you want to align:

  1. Keep rewrite_command_with_policy and requires_ask, and wire it into feat(rewrite): rewrite commands bundles #2781's clamp: Allow requires attestable && !requires_ask && verdict == Allow, everything else that rewrites is Ask. Either signal can force ask; both must agree to auto-allow.
  2. Apply your logic per statement: rewrite_command now splits multi-line bundles and routes each statement through rewrite_logical_line (registry.rs)
  3. Existing tests assert git log -10 | grep feat → rewritten; your consumer-aware rule changes that to raw. Update them as intended behavior.

Before merge

  1. rerun test and usage with changes
  2. solve any conflicts

@aeppling aeppling mentioned this pull request Jul 13, 2026
5 tasks done
@ahundt
ahundt force-pushed the v3/lexer-command-analysis-superset branch from 7efa13e to ffd7b3b Compare August 2, 2026 19:28
@ahundt ahundt changed the title Improve command rewrite success rate for pipes and redirects (lexer) Preserve pipeline input while rewriting safe shell commands Aug 2, 2026
@ahundt
ahundt force-pushed the v3/lexer-command-analysis-superset branch from ffd7b3b to 1f74d09 Compare August 3, 2026 16:18
ahundt added 5 commits August 3, 2026 12:32
Before this change, rewrite_command re-tokenized each normalized command in has_heredoc, contains_shell_block, contains_compound_boundary, and rewrite_compound. Long multiline Bash input therefore paid repeated O(n) scans and repeated token-vector allocation, while shell-block detection used independent conditional branches.

Tokenize the normalized command once in rewrite_command and pass the shared ParsedToken slice through heredoc, shell-block, compound-boundary, pipeline, and compound-rewrite analysis. Replace shell-block branching with an explicit match-based state transition that recognizes comments, escaped newlines, case alternatives, grouping, substitutions, and control keywords while preserving fail-open behavior for heredocs and control blocks. Preserve indentation and exact LF/CRLF separators when rewriting safe top-level command lists.

Files: src/discover/lexer.rs (token analysis, comment/newline handling, case-pattern handling, and shell-block state transitions); src/discover/registry.rs (one-pass rewrite analysis and multiline command-list rendering).

Verification: cargo test -q discover::lexer::tests (109 passed); cargo test -q discover::registry::tests (344 passed); HOME=/private/tmp/rtk-v4-home RTK_TEE=1 cargo test -q (2531 passed, 8 ignored); cargo check -q; cargo clippy --all-targets --all-features -- -D warnings; cargo fmt --all -- --check; git diff --check.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
Before this change, rewrite_compound sent the final pipeline stage to rewrite_segment_inner. A command such as cargo test | grep FAILED could therefore rewrite the content-sensitive consumer and change the producer output seen by grep. The registry also had a second trailing-redirect parser, leaving file-target permission ownership split across helpers.

Route only the producer when pipe_tail_accepts_filtered_output confirms that every remaining stage is an stdin-only head, tail, or cat invocation. Keep grep, rg, tee, wrapped consumers, stderr pipelines, and malformed stages raw. The decision walks the ParsedToken slice already created by rewrite_command, preserving one-pass command analysis for the pipeline path.

Move redirect suffix ownership into src/discover/suffix.rs, preserve the original suffix bytes, leave file targets in the command core, and expose RewriteResult so rewrite_cmd and the native Claude, Copilot, and Droid hooks make file redirects ask-only while command substitutions remain pass-through. Remove the obsolete pipeline_final_safe rule metadata and its final-stage tests.

Files: src/discover/lexer.rs (construct classification and fd-redirect ownership); src/discover/registry.rs (producer routing, shared policy result, and regression tests); src/discover/suffix.rs and src/discover/mod.rs (lexer-backed suffix handling); src/discover/rules.rs and src/discover/README.md (remove stale final-stage contract); src/hooks/rewrite_cmd.rs and src/hooks/hook_cmd.rs (protocol decisions and payload tests).

Verification: discover::lexer::tests 109 passed; discover::registry::tests 343 passed; hooks::rewrite_cmd::tests 15 passed; hooks::hook_cmd::tests 98 passed; full cargo test 2533 passed, 8 ignored; cargo check -q; cargo clippy --all-targets --all-features -- -D warnings; cargo fmt --all -- --check; git diff --check.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
Before this change, removing the hook-level substitution defer allowed a multiline command containing a substitution to reach the normal rewrite path. A simple command such as `git log $(git rev-parse HEAD~1)` could be rewritten, and an allowed multiline bundle could be auto-allowed even though its complete command was not attestable.

Guard `rewrite_segment_inner` in `src/discover/registry.rs` so substitution-bearing segments remain verbatim while safe top-level statements in the same newline bundle can still be rewritten. Gate `decide_from_verdict` in `src/hooks/hook_cmd.rs` with the same whole-command attestability check used by `evaluate_with_verdict` in `src/hooks/rewrite_cmd.rs`, preserving ask-only handling for mixed bundles and file-target redirects.

Refs rtk-ai#1155; aligns the multiline permission contract reviewed in PR rtk-ai#2781 without importing its unrelated branch history.

Verification: `cargo test -q` (2535 passed, 8 ignored); `cargo test -q discover::registry::tests::test_rewrite` (192 passed); `cargo test -q hooks::hook_cmd::tests` (99 passed); `cargo test -q hooks::rewrite_cmd::tests` (16 passed); `cargo check -q`; `cargo fmt --all -- --check`; `git diff --check`.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
Replace the removed struct-variant patterns in the multiline substitution and file redirect assertions in src/hooks/hook_cmd.rs. The tests now follow upstream/develop's tuple HookDecision::AskRewrite(String) while retaining exact rewritten-command checks.\n\nVerification: env HOME=/private/tmp/rtk-lexer-test-home-20260802 RUSTUP_HOME=/Users/athundt/.rustup CARGO_HOME=/Users/athundt/.cargo RUSTC_WRAPPER= cargo test -q -- --test-threads=1 (2541 passed, 8 ignored).
rewrite_multiline_block still assumed one-byte newline separators after the upstream multiline rewrite changes, and it passed continued shell lines to the single-line router with embedded newlines. Record each ParsedToken separator length so CRLF blocks preserve their bytes, join continued &&/||/| units with shell-equivalent spaces, and fail closed when a continued logical unit cannot be routed safely.\n\nThe stronger lexer treats apostrophes inside comments as inert while retaining the original comment text. Raw grep pipelines remain unmodified, while stdin-only head/tail display tails remain eligible for producer rewriting.\n\nFiles: src/discover/registry.rs.\nVerification: discover::registry::tests 372 passed; discover::lexer::tests 109 passed; hooks::rewrite_cmd::tests 16 passed; hooks::hook_cmd::tests 103 passed; cargo check -q; cargo fmt --all -- --check; git diff --check.\n\nSigned-off-by: Andrew Hundt <ATHundt@gmail.com>
@ahundt
ahundt force-pushed the v3/lexer-command-analysis-superset branch from 1f74d09 to 968cf99 Compare August 3, 2026 18:15
@ahundt

ahundt commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the multiline-rewrite feedback in the current head (968cf99). The branch now uses src/discover/registry.rs::rewrite_multiline_block with the shared lexer’s quote-aware newline tokens, rewrites independent clean lines, preserves raw pipeline consumers and substitution-bearing statements, and defers unsafe shell blocks. src/hooks/rewrite_cmd.rs keeps the policy path: unattestable rewrites remain Ask-only, and file-target redirects retain requires_ask. I validated the current head with 2634 passed, 8 ignored, cargo fmt --all -- --check, cargo clippy --locked --all-targets --all-features -- -D warnings, git diff --check, and a clean merge-tree check against current upstream/develop. I did not merge the older overlapping #2781 implementation because it conflicts with this newer lexer/policy implementation; the requested behavior is already present in this head and its follow-up commits.

Why: PR rtk-ai#2555 must be current with develop without losing consumer-aware pipelines, multiline statement routing, or Ask-only redirects.

What: Merge current upstream/develop and resolve hook conflicts by retaining the branch RewriteResult policy while adding upstream Copilot self-healing and Vibe support. Keep host-independent rewrite tests isolated from local permission settings.

Verification: cargo fmt --all; cargo check --all-targets; 18 rewrite-command tests; 109 hook-command tests; git diff --check.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
ahundt added a commit to ahundt/rtk that referenced this pull request Aug 24, 2026
Why:
The combined integration proof must include the latest PR rtk-ai#2555 behavior on top of current upstream/develop without replacing either history.

What:
Merge v4/lexer-command-analysis-bash-blocks, preserving multiline Bash parsing, consumer-aware raw routing, suffix analysis, Ask-only safe rewrites, and upstream Copilot and Vibe behavior.

Verification:
- git diff --cached --check
- inspected staged 8-file capability scope before commit

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
@KuSh

KuSh commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Round 2 of 3 — DUPLICATE, recommend closing

Claim: Lex shell structure before rewriting so pipeline/redirect content consumers receive the producer's original output (fixes #1560).

Scope: duplicate — superseded by #3128 (with #3343 PipelineSafety and #3704 lexer consolidation). Issue #1560, this PR's entire target, was closed 2026-09-05 as fixed on develop.

Ran: both binaries built from upstream/develop (d0c2985) and this head (9e8d128); rtk rewrite over 28 command shapes — content consumers (grep, rg, tee, xargs, wc, awk, sed, less), safe display tails (head, tail, cat), redirects (>, >>, <, 2>&1), substitutions, heredocs, if/for/case/(...)/{...} blocks, quoting and comment edge cases; plus a live correctness check of develop's rewrite against real git log output.

CI @9e8d1285: 10 green, benchmark failing on find --max 10 (-20%) and golangci-lint (empty output) — both pre-existing and untouched by this diff. Head is 3 weeks stale and conflicts with develop in every file it touches.

Why develop already covers this

Develop fixes #1560 by a different and strictly better mechanism: it leaves the producer raw and rewrites the consumer, instead of leaving the whole pipeline raw.

git log | grep feat
  develop: git log | rtk grep feat      <- git log raw, grep reads the real stream
  this PR: <raw>                        <- correct, but no savings

Verified the survivor is genuinely correct, not just smaller: git log -50 | grep feat and git log -50 | rtk grep feat both yield 17 matching lines, so grep is reading the unmodified Git stream and rtk grep only compacts the display.

Every consumer this PR names is already handled on develop — tee, xargs, wc, awk, sed, less stay fully raw on both; head, tail, cat are rewritten as safe display tails on both.

The residual delta is a regression

Because this PR drops the whole statement to raw where develop rewrites the consumer, it loses savings that develop keeps:

command develop this PR
git diff HEAD~30 | grep '^+' 1,485,061 → 8,483 B (99%) 0%
git log 2>&1 | grep feat git log 2>&1 | rtk grep feat raw
git log|grep feat git log| rtk grep feat raw
git status && git log | grep feat both stages rewritten tail left raw
if git status; then git log | grep feat; fi rewritten, producer raw raw

Against the 20% floor (CONTRIBUTING.md), these go from 99% to 0% on a very common shape.

The one novel piece is unsafe

The file-redirect rewrite is the only behavior here develop lacks, and it reintroduces the content-sink hazard this PR exists to close:

git status > /tmp/status.log   ->   rtk git status > /tmp/status.log

The user's file then holds RTK's compact rendering (57 B) instead of the raw output (123 B) — a content consumer being fed display output, the same class as #1560, just a file sink instead of a pipe. requires_ask narrows the blast radius but does not make it correct; develop's refusal to rewrite is the safe behavior. >> behaves the same.

Relatedly, the PR body states "Input redirects … remain conservative", but git log < /tmp/in is rewritten to rtk git log < /tmp/in on this head, while develop leaves it raw.

Your questions

Next

Recommend closing as superseded by #3128. The analysis in this PR was right about the bug and about the consumer/display-tail distinction — develop reached the same conclusions and kept the savings. Nothing in the remaining diff is worth rebasing through conflicts in all six files.

Rounds: 2/3. Threads: 0 open, 0 resolved.

@KuSh KuSh closed this Sep 13, 2026
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.

bug(rewrite): hook rewrites left-hand side of piped commands, breaking downstream filters

5 participants