Skip to content

Silent output corruption when stdout is piped or redirected (suggest isatty passthrough) #1282

@panwudi

Description

@panwudi

Summary

When rtk rewrites a command (e.g. grep, find, ls) and the output is piped into another program or redirected to a file, the compressed human-readable format silently corrupts the downstream consumer. The caller usually has no way to detect this until a downstream assertion fails.

We suggest RTK follow the same convention as ls --color=auto, git diff, grep --color=auto, etc.: check isatty(stdout) at runtime, and fall back to raw passthrough whenever stdout is not a TTY (pipe, redirect, or process substitution).

Reproduction

Environment: rtk 0.36.0, Claude Code PreToolUse hook (rtk-rewrite.sh v3), Linux.

# A file with exactly 83 lines matching the pattern                                                                                                                                                                                                                                                     
$ grep -cE '^- \[ \]' TODO.md                                                                                                                                                                                                                                                                             
83                                                                                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                                          
# Naive redirection through RTK hook rewrite                                                                                                                                                                                                                                                              
$ grep -n '^- \[ \]' TODO.md > /tmp/pending.txt                                                                                                                                                                                                                                                         
$ wc -l /tmp/pending.txt                                                                                                                                                                                                                                                                                  
136 /tmp/pending.txt   # <-- WRONG, expected 83                                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                                                                                          
$ head -3 /tmp/pending.txt                                                                                                                                                                                                                                                                              
83 matches in 36F:                                                                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                                          
// ... 134 more lines (total: 136)...                                                                                                                                                                                                                                                                     

The file contains RTK's compressed human summary ("83 matches in 36F", "// ... 134 more lines", per-file headers like "[file] 921 (1):"), not raw grep output.

Using rtk proxy as a manual opt-out recovers correctness:

$ rtk proxy grep -n '^- \[ \]' TODO.md > /tmp/pending.txt                                                                                                                                                                                                                                               
$ wc -l /tmp/pending.txt                                                                                                                                                                                                                                                                                  
83 /tmp/pending.txt   # correct

Why this matters

  1. Silent failure. The downstream consumer (wc -l, jq, diff, curl -d @file) sees syntactically valid but semantically wrong input. No error message, no exit code hint — the only signal is a factual discrepancy the caller may or may not notice.

  2. Compounds through pipelines. If the corrupted output is fed to an LLM subagent (our case: classifying 83 TODO items via a MiniMax subagent), the downstream LLM receives garbage input and produces confidently wrong output. Correctness has to be verified via counts/diffs afterwards, because
    the corruption leaves no exception trail.

  3. AI agent risk. RTK's primary audience is LLM hooks (per the README). LLM agents frequently pipe grep/find output into jq/awk/other tools to build reasoning pipelines. A compression mode that is opt-out rather than opt-in is hostile to this use case.

Suggested fix

In the Rust code path that emits compressed output, add an IsTerminal check before applying compression:

use std::io::IsTerminal;                                                                                                                                                                                                                                                                                
                                                                                                                                                                                                                                                                                                          
if std::io::stdout().is_terminal() {
    print_compressed(output);                                                                                                                                                                                                                                                                             
} else {                                                                                                                                                                                                                                                                                                  
    print_raw_passthrough(output);
}                                                                                                                                                                                                                                                                                                         

std::io::IsTerminal is stable since Rust 1.70, so no external crate is needed.

This is the same contract as:

  • ls --color=auto
  • grep --color=auto
  • git diff (pager auto-detection)
  • cargo (coloring)

Users who explicitly want compression in a non-TTY context can still opt in via an env var (e.g. RTK_FORCE_COMPACT=1) or a rtk compact <cmd> subcommand.

Workaround we adopted

Until this is fixed upstream, we added a redirection/pipe detection layer to our rtk-rewrite.sh PreToolUse hook:

# Bypass RTK rewrite when the command shape indicates the output will be                                                                                                                                                                                                                                
# consumed by a program (file, pipe, substitution) instead of displayed.                                                                                                                                                                                                                                  
if [[ "$CMD" =~ [\>\|\`] ]] || [[ "$CMD" == *'$('* ]] || [[ "$CMD" == *'<('* ]]; then
  exit 0  # pass through unchanged — no RTK rewrite                                                                                                                                                                                                                                                       
fi                                                                                                                                                                                                                                                                                                        

This works at the hook layer but does not help users who invoke rtk directly from an interactive terminal and then pipe it somewhere unexpectedly. An upstream fix is strictly better.

Design note

RTK's current default is compress unless opted out via rtk proxy. We would gently suggest the inverted default is safer: raw passthrough unless stdout is a TTY. Compression is valuable for humans reading a terminal; it is actively harmful for machines reading a stream. The TTY boundary is the
most reliable signal for which audience is on the other end.

Environment

  • rtk version: 0.36.0
  • OS: Linux 6.8.0-106-generic
  • Shell: zsh / bash
  • Consumer: Claude Code LLM hook (rtk-rewrite.sh v3)

Happy to file a PR implementing the IsTerminal check if the maintainers agree with the direction.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions