1use anyhow::Result;
18use base64::Engine as _;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use serde::Deserialize;
21use serde_json::{json, Value};
22use std::io::{Read, Write};
23
24#[derive(Deserialize)]
27struct PreToolUseInput {
28 tool_name: String,
29 #[serde(default)]
30 tool_use_id: String,
31 #[serde(default)]
32 session_id: String,
33 #[serde(default)]
34 tool_input: Value,
35}
36
37const WRAPPER_BINARY: &str = "agentmux-bashwrap";
38const WRAPPED_PREFIX: &str = "agentmux-bashwrap exec";
39
40pub fn run_pretooluse_bash() -> Result<()> {
41 let mut buf = String::new();
43 std::io::stdin().read_to_string(&mut buf)?;
44
45 let response = if crate::prefix::prefix_active() {
50 record_response(&buf)
51 } else {
52 build_response(&buf)
53 };
54 let stdout = std::io::stdout();
55 let mut handle = stdout.lock();
56 writeln!(handle, "{}", response)?;
57 Ok(())
58}
59
60pub fn build_response(stdin_payload: &str) -> Value {
64 let input: PreToolUseInput = match serde_json::from_str(stdin_payload) {
65 Ok(i) => i,
66 Err(_) => {
67 return passthrough();
70 }
71 };
72
73 if input.tool_name != "Bash" {
74 return passthrough();
75 }
76
77 let command = input
78 .tool_input
79 .get("command")
80 .and_then(|v| v.as_str())
81 .unwrap_or("");
82
83 if command.starts_with(WRAPPED_PREFIX) {
86 return passthrough();
87 }
88
89 let effective_command =
95 tee_redirect_rewrite(command).unwrap_or_else(|| command.to_string());
96
97 let declared_background = input
106 .tool_input
107 .get("run_in_background")
108 .and_then(|v| v.as_bool())
109 .unwrap_or(false);
110
111 let b64 = URL_SAFE_NO_PAD.encode(effective_command.as_bytes());
112 let wrapped = format!(
113 "{} exec --tool-id={} --b64-cmd={}{}",
114 WRAPPER_BINARY,
115 shell_quote(&input.tool_use_id),
116 b64,
117 if declared_background { " --declared-background" } else { "" }
118 );
119
120 let mut updated = match input.tool_input {
128 Value::Object(fields) => fields,
129 _ => serde_json::Map::new(),
130 };
131 updated.insert("command".to_string(), Value::String(wrapped));
132
133 json!({
134 "hookSpecificOutput": {
135 "hookEventName": "PreToolUse",
136 "permissionDecision": "allow",
137 "updatedInput": updated
138 }
139 })
140}
141
142pub fn record_response(stdin_payload: &str) -> Value {
149 let Ok(input) = serde_json::from_str::<PreToolUseInput>(stdin_payload) else {
150 return passthrough();
151 };
152 let command = input.tool_input.get("command").and_then(|v| v.as_str()).unwrap_or("");
153 let usable = input.tool_name == "Bash"
154 && !input.tool_use_id.is_empty()
155 && !input.session_id.is_empty()
156 && !command.starts_with(WRAPPED_PREFIX);
157 if usable {
158 let background = input.tool_input.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false);
159 let mut record = crate::prefix::new_record(&input.tool_use_id, command, background);
160 record.description = input
161 .tool_input
162 .get("description")
163 .and_then(|v| v.as_str())
164 .map(str::trim)
165 .filter(|d| !d.is_empty())
166 .map(str::to_string);
167 if let Err(e) = crate::prefix::register(&input.session_id, &record) {
168 tracing::warn!(target: "bashwrap", tool_id = %input.tool_use_id, error = %e, "couldn't record the Bash call; it runs unstreamed");
169 }
170 }
171 passthrough()
172}
173
174fn passthrough() -> Value {
175 json!({})
177}
178
179#[derive(Clone, Copy)]
208enum OutKind {
209 Out,
210 Append,
211 AmpOut,
212 AmpAppend,
213}
214
215struct ScanResult {
216 redir_start: usize,
217 target_start: usize,
218 target_end: usize,
219 append: bool,
220 combine_stderr: bool,
221}
222
223pub(crate) fn tee_redirect_rewrite(raw: &str) -> Option<String> {
226 let cmd = raw.trim();
227 if cmd.is_empty() {
228 return None;
229 }
230 let scan = scan_redirect(cmd)?;
231 let cmd_part = cmd[..scan.redir_start].trim();
232 if cmd_part.is_empty() {
233 return None;
234 }
235 let target = cmd[scan.target_start..scan.target_end].trim();
236 if target.is_empty() || is_dev_null(target) {
237 return None;
238 }
239 let aflag = if scan.append { "-a " } else { "" };
240 let stream = if scan.combine_stderr { " 2>&1 |" } else { " |" };
241 Some(format!(
242 "set -o pipefail; {{ {} ; }}{} tee {}-- {}",
243 cmd_part, stream, aflag, target
244 ))
245}
246
247fn is_space(c: u8) -> bool {
248 c == b' ' || c == b'\t' || c == b'\n' || c == b'\r'
249}
250
251fn is_dev_null(target: &str) -> bool {
253 let t = target.trim();
254 let unq = if (t.starts_with('"') && t.ends_with('"') && t.len() >= 2)
255 || (t.starts_with('\'') && t.ends_with('\'') && t.len() >= 2)
256 {
257 &t[1..t.len() - 1]
258 } else {
259 t
260 };
261 unq == "/dev/null"
262}
263
264fn scan_redirect(cmd: &str) -> Option<ScanResult> {
267 let b = cmd.as_bytes();
268 let n = b.len();
269 let mut i = 0usize;
270 let mut in_single = false;
271 let mut in_double = false;
272 let mut in_backtick = false;
273 let mut paren_depth: i32 = 0;
274 let mut brace_depth: i32 = 0; let mut out_redirs: Vec<(OutKind, usize, usize)> = Vec::new();
277 let mut dups: Vec<(usize, usize)> = Vec::new(); let mut other_redir = false;
279 let mut list_op = false;
280
281 while i < n {
282 let c = b[i];
283 if in_single {
284 if c == b'\'' {
285 in_single = false;
286 }
287 i += 1;
288 continue;
289 }
290 if in_double {
291 if c == b'\\' && i + 1 < n {
292 i += 2;
293 continue;
294 }
295 if c == b'"' {
296 in_double = false;
297 }
298 i += 1;
299 continue;
300 }
301 if in_backtick {
302 if c == b'`' {
303 in_backtick = false;
304 }
305 i += 1;
306 continue;
307 }
308 match c {
310 b'\\' => {
311 i += 2;
312 continue;
313 }
314 b'\'' => {
315 in_single = true;
316 i += 1;
317 continue;
318 }
319 b'"' => {
320 in_double = true;
321 i += 1;
322 continue;
323 }
324 b'`' => {
325 in_backtick = true;
326 i += 1;
327 continue;
328 }
329 b'#' => {
330 let prev = if i == 0 { b' ' } else { b[i - 1] };
332 if is_space(prev) || matches!(prev, b';' | b'|' | b'&' | b'(' | b'`') {
333 return None;
334 }
335 i += 1;
336 continue;
337 }
338 b'(' => {
339 paren_depth += 1;
340 i += 1;
341 continue;
342 }
343 b')' => {
344 if paren_depth > 0 {
345 paren_depth -= 1;
346 }
347 i += 1;
348 continue;
349 }
350 b'$' => {
351 if i + 1 < n && b[i + 1] == b'{' {
355 brace_depth += 1;
356 i += 2;
357 } else {
358 i += 1;
359 }
360 continue;
361 }
362 b'}' => {
363 if brace_depth > 0 {
364 brace_depth -= 1;
365 }
366 i += 1;
367 continue;
368 }
369 _ => {}
370 }
371 if paren_depth > 0 || brace_depth > 0 {
375 i += 1;
376 continue;
377 }
378 match c {
380 b'<' => {
381 if i + 1 < n && (b[i + 1] == b'(' || b[i + 1] == b'<') {
382 return None; }
384 other_redir = true; i += 1;
386 }
387 b'>' => {
388 if i + 1 < n && b[i + 1] == b'(' {
389 return None; }
391 if i > 0 && b[i - 1].is_ascii_digit() {
392 if b[i - 1] == b'2' && i + 2 < n && b[i + 1] == b'&' && b[i + 2] == b'1' {
394 dups.push((i - 1, i + 3));
395 i += 3;
396 } else {
397 other_redir = true;
398 i += 1;
399 }
400 } else if i + 1 < n && b[i + 1] == b'>' {
401 out_redirs.push((OutKind::Append, i, i + 2));
402 i += 2;
403 } else if i + 1 < n && b[i + 1] == b'&' {
404 other_redir = true; i += 1;
406 } else {
407 out_redirs.push((OutKind::Out, i, i + 1));
408 i += 1;
409 }
410 }
411 b'&' => {
412 if i + 1 < n && b[i + 1] == b'&' {
413 list_op = true;
414 i += 2;
415 } else if i + 1 < n && b[i + 1] == b'>' {
416 if i + 2 < n && b[i + 2] == b'>' {
417 out_redirs.push((OutKind::AmpAppend, i, i + 3));
418 i += 3;
419 } else {
420 out_redirs.push((OutKind::AmpOut, i, i + 2));
421 i += 2;
422 }
423 } else {
424 list_op = true; i += 1;
426 }
427 }
428 b'|' => {
429 list_op = true;
436 i += if i + 1 < n && b[i + 1] == b'|' { 2 } else { 1 };
437 }
438 b';' | b'\n' | b'\r' => {
444 list_op = true;
445 i += 1;
446 }
447 _ => {
448 i += 1;
449 }
450 }
451 }
452 if in_single || in_double || in_backtick {
453 return None; }
455 if list_op || other_redir || out_redirs.len() != 1 {
456 return None;
457 }
458 let (kind, rstart, rend) = out_redirs[0];
459 if dups.iter().any(|(ds, _)| *ds < rstart) {
462 return None;
463 }
464 let (tstart, tend) = read_word(b, rend)?;
466 let mut j = skip_spaces(b, tend);
468 let mut combine = matches!(kind, OutKind::AmpOut | OutKind::AmpAppend);
469 if let Some((_, de)) = dups.iter().find(|(ds, _)| *ds == j) {
470 combine = true;
471 j = skip_spaces(b, *de);
472 }
473 if j != n {
474 return None; }
476 Some(ScanResult {
477 redir_start: rstart,
478 target_start: tstart,
479 target_end: tend,
480 append: matches!(kind, OutKind::Append | OutKind::AmpAppend),
481 combine_stderr: combine,
482 })
483}
484
485fn skip_spaces(b: &[u8], mut i: usize) -> usize {
486 while i < b.len() && is_space(b[i]) {
487 i += 1;
488 }
489 i
490}
491
492fn read_word(b: &[u8], from: usize) -> Option<(usize, usize)> {
495 let n = b.len();
496 let mut i = skip_spaces(b, from);
497 if i >= n {
498 return None;
499 }
500 let start = i;
501 let mut in_single = false;
502 let mut in_double = false;
503 while i < n {
504 let c = b[i];
505 if in_single {
506 if c == b'\'' {
507 in_single = false;
508 }
509 i += 1;
510 continue;
511 }
512 if in_double {
513 if c == b'\\' && i + 1 < n {
514 i += 2;
515 continue;
516 }
517 if c == b'"' {
518 in_double = false;
519 }
520 i += 1;
521 continue;
522 }
523 match c {
524 b'\'' => {
525 in_single = true;
526 i += 1;
527 }
528 b'"' => {
529 in_double = true;
530 i += 1;
531 }
532 b'\\' => {
533 i += 2;
534 }
535 _ if is_space(c) => break,
536 b'>' | b'<' | b'|' | b'&' | b';' | b'(' | b')' => break,
538 _ => {
539 i += 1;
540 }
541 }
542 }
543 if i == start {
544 return None;
545 }
546 Some((start, i))
547}
548
549fn shell_quote(s: &str) -> String {
564 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
565 return s.to_string();
566 }
567 #[cfg(windows)]
568 {
569 let escaped = s.replace('"', "\"\"");
573 format!("\"{}\"", escaped)
574 }
575 #[cfg(not(windows))]
576 {
577 let escaped = s.replace('\'', r"'\''");
579 format!("'{}'", escaped)
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use serde_json::json;
587
588 fn payload(cmd: &str, id: &str) -> String {
589 json!({
590 "tool_name": "Bash",
591 "tool_use_id": id,
592 "tool_input": { "command": cmd },
593 "session_id": "sess-1",
594 "hook_event_name": "PreToolUse"
595 })
596 .to_string()
597 }
598
599 #[test]
600 fn rewrites_simple_bash_command() {
601 let resp = build_response(&payload("echo hi", "toolu_abc"));
602 let cmd = resp["hookSpecificOutput"]["updatedInput"]["command"]
603 .as_str()
604 .unwrap();
605 assert!(cmd.starts_with("agentmux-bashwrap exec"));
606 assert!(cmd.contains("--tool-id=toolu_abc"));
607 assert!(cmd.contains("--b64-cmd=ZWNobyBoaQ"));
609 assert_eq!(
610 resp["hookSpecificOutput"]["permissionDecision"].as_str(),
611 Some("allow")
612 );
613 }
614
615 #[test]
616 fn preserves_multi_line_and_quotes_via_base64() {
617 let cmd = r#"echo "hello" && echo 'multi
618line' && cat $HOME/.env"#;
619 let resp = build_response(&payload(cmd, "toolu_x"));
620 let wrapped = resp["hookSpecificOutput"]["updatedInput"]["command"]
621 .as_str()
622 .unwrap();
623 let b64 = wrapped
625 .split("--b64-cmd=")
626 .nth(1)
627 .expect("b64 arg present");
628 let decoded = URL_SAFE_NO_PAD.decode(b64.as_bytes()).unwrap();
629 let decoded = String::from_utf8(decoded).unwrap();
630 assert_eq!(decoded, cmd);
631 }
632
633 #[test]
634 fn is_no_op_on_already_wrapped_command() {
635 let cmd =
636 "agentmux-bashwrap exec --tool-id=toolu_y --b64-cmd=ZWNobyBoaQ";
637 let resp = build_response(&payload(cmd, "toolu_y"));
638 assert!(
639 resp.as_object().map(|m| m.is_empty()).unwrap_or(false),
640 "should pass through (empty object), got {}",
641 resp
642 );
643 }
644
645 #[test]
646 fn passes_through_non_bash_tools() {
647 let payload = json!({
648 "tool_name": "Read",
649 "tool_use_id": "toolu_r",
650 "tool_input": { "file_path": "x.txt" }
651 })
652 .to_string();
653 let resp = build_response(&payload);
654 assert!(
655 resp.as_object().map(|m| m.is_empty()).unwrap_or(false),
656 "non-Bash should pass through"
657 );
658 }
659
660 #[test]
661 fn passes_through_malformed_input() {
662 let resp = build_response("not json");
663 assert!(
664 resp.as_object().map(|m| m.is_empty()).unwrap_or(false),
665 "malformed → passthrough"
666 );
667 }
668
669 #[test]
670 fn rewrites_with_empty_b64_when_command_field_missing() {
671 let payload = json!({
686 "tool_name": "Bash",
687 "tool_use_id": "toolu_z",
688 "tool_input": {}
689 })
690 .to_string();
691 let resp = build_response(&payload);
692 let cmd = resp["hookSpecificOutput"]["updatedInput"]["command"]
693 .as_str()
694 .unwrap();
695 assert!(
699 cmd.contains("--b64-cmd=") && (cmd.ends_with("--b64-cmd=") || cmd.contains("--b64-cmd= ")),
700 "expected empty b64-cmd argument, got: {}",
701 cmd
702 );
703 }
704
705 #[test]
706 fn threads_declared_background_flag_when_run_in_background_true() {
707 let payload = json!({
708 "tool_name": "Bash",
709 "tool_use_id": "toolu_bg",
710 "tool_input": { "command": "task dev", "run_in_background": true }
711 })
712 .to_string();
713 let resp = build_response(&payload);
714 let cmd = resp["hookSpecificOutput"]["updatedInput"]["command"]
715 .as_str()
716 .unwrap();
717 assert!(
718 cmd.ends_with(" --declared-background"),
719 "expected the wrapped command to carry --declared-background, got: {cmd}"
720 );
721 }
722
723 #[test]
724 fn omits_declared_background_flag_when_run_in_background_false_or_absent() {
725 let with_false = json!({
726 "tool_name": "Bash",
727 "tool_use_id": "toolu_fg",
728 "tool_input": { "command": "echo hi", "run_in_background": false }
729 })
730 .to_string();
731 let without_field = json!({
732 "tool_name": "Bash",
733 "tool_use_id": "toolu_fg2",
734 "tool_input": { "command": "echo hi" }
735 })
736 .to_string();
737 for payload in [with_false, without_field] {
738 let resp = build_response(&payload);
739 let cmd = resp["hookSpecificOutput"]["updatedInput"]["command"]
740 .as_str()
741 .unwrap();
742 assert!(
743 !cmd.contains("--declared-background"),
744 "expected no --declared-background flag, got: {cmd}"
745 );
746 }
747 }
748
749 #[test]
754 fn keeps_every_other_field_of_the_tool_input() {
755 let payload = json!({
756 "tool_name": "Bash",
757 "tool_use_id": "toolu_keep",
758 "tool_input": {
759 "command": "task dev",
760 "description": "Start the dev build",
761 "timeout": 7_200_000,
762 "run_in_background": true,
763 "dangerouslyDisableSandbox": false,
764 "some_future_field": { "nested": [1, 2] }
765 }
766 })
767 .to_string();
768 let resp = build_response(&payload);
769 let updated = &resp["hookSpecificOutput"]["updatedInput"];
770 assert!(updated["command"].as_str().unwrap().starts_with("agentmux-bashwrap exec"));
771 assert_eq!(updated["description"], json!("Start the dev build"));
772 assert_eq!(updated["timeout"], json!(7_200_000));
773 assert_eq!(updated["run_in_background"], json!(true));
774 assert_eq!(updated["dangerouslyDisableSandbox"], json!(false));
775 assert_eq!(updated["some_future_field"], json!({ "nested": [1, 2] }));
776 assert_eq!(updated.as_object().unwrap().len(), 6);
777 }
778
779 #[test]
782 fn record_mode_records_the_call_and_answers_nothing() {
783 let _guard = crate::test_env_lock::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
784 let dir = std::env::temp_dir().join(format!("bashwrap-hook-record-{}", std::process::id()));
785 std::env::set_var("AGENTMUX_BASHWRAP_CALLS_DIR", &dir);
786 let payload = json!({
787 "tool_name": "Bash",
788 "tool_use_id": "toolu_rec",
789 "session_id": "sess-rec",
790 "tool_input": { "command": "cargo build", "description": "Build", "run_in_background": true }
791 })
792 .to_string();
793 let resp = record_response(&payload);
794 let claimed = crate::prefix::claim("sess-rec", "cargo build");
795 let described = claimed.as_ref().and_then(|c| c.description.clone());
796 let wrapped = json!({
797 "tool_name": "Bash", "tool_use_id": "toolu_w", "session_id": "sess-rec",
798 "tool_input": { "command": "agentmux-bashwrap exec --tool-id=x --b64-cmd=eA" }
799 })
800 .to_string();
801 let resp_wrapped = record_response(&wrapped);
802 let left = crate::prefix::claim("sess-rec", "any command");
803 std::env::remove_var("AGENTMUX_BASHWRAP_CALLS_DIR");
804 let _ = std::fs::remove_dir_all(&dir);
805
806 assert_eq!(resp, json!({}));
807 let claimed = claimed.expect("the call was recorded");
808 assert_eq!((claimed.tool_use_id.as_str(), claimed.run_in_background), ("toolu_rec", true));
809 assert_eq!(described.as_deref(), Some("Build"), "the call's description, for Tower");
810 assert_eq!(resp_wrapped, json!({}));
811 assert_eq!(left, None, "an already-wrapped command isn't recorded");
812 }
813
814 #[test]
815 fn record_mode_ignores_other_tools_and_malformed_payloads() {
816 assert_eq!(record_response("not json"), json!({}));
817 let read = json!({ "tool_name": "Read", "tool_use_id": "t", "session_id": "s", "tool_input": {} }).to_string();
818 assert_eq!(record_response(&read), json!({}));
819 }
820
821 #[test]
822 fn a_tool_input_that_is_not_an_object_still_gets_the_command() {
823 let payload = json!({
824 "tool_name": "Bash",
825 "tool_use_id": "toolu_odd",
826 "tool_input": "echo hi"
827 })
828 .to_string();
829 let resp = build_response(&payload);
830 let updated = resp["hookSpecificOutput"]["updatedInput"].as_object().unwrap();
831 assert_eq!(updated.len(), 1);
832 assert!(updated["command"].as_str().unwrap().starts_with("agentmux-bashwrap exec"));
833 }
834
835 #[test]
836 fn shell_quote_passes_safe_ids_unchanged() {
837 assert_eq!(shell_quote("toolu_abc-123"), "toolu_abc-123");
838 }
839
840 #[cfg(not(windows))]
841 #[test]
842 fn shell_quote_wraps_unsafe_ids_unix() {
843 let q = shell_quote("weird id");
844 assert_eq!(q, "'weird id'");
845 }
846
847 #[cfg(not(windows))]
848 #[test]
849 fn shell_quote_escapes_embedded_single_quotes_unix() {
850 let q = shell_quote("o'malley");
853 assert_eq!(q, r"'o'\''malley'");
854 }
855
856 #[cfg(windows)]
857 #[test]
858 fn shell_quote_wraps_unsafe_ids_windows() {
859 let q = shell_quote("weird id");
860 assert_eq!(q, "\"weird id\"");
861 }
862
863 #[cfg(windows)]
864 #[test]
865 fn shell_quote_escapes_embedded_double_quotes_windows() {
866 let q = shell_quote("a\"b");
869 assert_eq!(q, "\"a\"\"b\"");
870 }
871
872 #[test]
875 fn tee_rewrites_basic_redirect_forms() {
876 assert_eq!(
877 tee_redirect_rewrite("task package > build.log").unwrap(),
878 "set -o pipefail; { task package ; } | tee -- build.log"
879 );
880 assert_eq!(
881 tee_redirect_rewrite("make >> out.log").unwrap(),
882 "set -o pipefail; { make ; } | tee -a -- out.log"
883 );
884 assert_eq!(
885 tee_redirect_rewrite("task package > build.log 2>&1").unwrap(),
886 "set -o pipefail; { task package ; } 2>&1 | tee -- build.log"
887 );
888 assert_eq!(
889 tee_redirect_rewrite("make >> out.log 2>&1").unwrap(),
890 "set -o pipefail; { make ; } 2>&1 | tee -a -- out.log"
891 );
892 assert_eq!(
893 tee_redirect_rewrite("cmd &> all.log").unwrap(),
894 "set -o pipefail; { cmd ; } 2>&1 | tee -- all.log"
895 );
896 assert_eq!(
897 tee_redirect_rewrite("cmd &>> all.log").unwrap(),
898 "set -o pipefail; { cmd ; } 2>&1 | tee -a -- all.log"
899 );
900 }
901
902 #[test]
903 fn tee_bails_on_internal_pipeline() {
904 assert!(tee_redirect_rewrite("cargo build | grep error > errors.log").is_none());
907 }
908
909 #[test]
910 fn tee_bails_on_multiline_command() {
911 assert!(tee_redirect_rewrite("echo building\nmake > build.log").is_none());
914 assert!(tee_redirect_rewrite("cd src\nmake > build.log 2>&1").is_none());
915 assert_eq!(
917 tee_redirect_rewrite("make \\\n --flag > build.log").unwrap(),
918 "set -o pipefail; { make \\\n --flag ; } | tee -- build.log"
919 );
920 }
921
922 #[test]
923 fn tee_handles_subshell_command() {
924 assert_eq!(
925 tee_redirect_rewrite("(cd src && make) > b.log 2>&1").unwrap(),
926 "set -o pipefail; { (cd src && make) ; } 2>&1 | tee -- b.log"
927 );
928 }
929
930 #[test]
931 fn tee_preserves_quoted_target() {
932 assert_eq!(
933 tee_redirect_rewrite(r#"cmd > "my out.log""#).unwrap(),
934 r#"set -o pipefail; { cmd ; } | tee -- "my out.log""#
935 );
936 }
937
938 #[test]
939 fn tee_bails_on_dev_null() {
940 assert!(tee_redirect_rewrite("noisy > /dev/null").is_none());
941 assert!(tee_redirect_rewrite("noisy > /dev/null 2>&1").is_none());
942 assert!(tee_redirect_rewrite(r#"noisy > "/dev/null""#).is_none());
943 }
944
945 #[test]
946 fn tee_bails_on_quoted_comment_heredoc_procsub() {
947 assert!(tee_redirect_rewrite("echo 'a > b'").is_none()); assert!(tee_redirect_rewrite("echo \"x > y\"").is_none()); assert!(tee_redirect_rewrite("make # writes > log").is_none()); assert!(tee_redirect_rewrite("cat <<EOF > f\nhi\nEOF").is_none()); assert!(tee_redirect_rewrite("diff <(a) > f").is_none()); }
953
954 #[test]
955 fn tee_bails_on_non_trailing_multiple_or_fd_redirects() {
956 assert!(tee_redirect_rewrite("cmd > f | grep x").is_none()); assert!(tee_redirect_rewrite("cmd > f && other").is_none()); assert!(tee_redirect_rewrite("a > f1 > f2").is_none()); assert!(tee_redirect_rewrite("cmd 2> err.log").is_none()); assert!(tee_redirect_rewrite("cmd >&2").is_none()); assert!(tee_redirect_rewrite("cmd 2>&1 > f").is_none()); assert!(tee_redirect_rewrite("echo hi").is_none()); assert!(tee_redirect_rewrite("server > log &").is_none()); }
965
966 #[test]
967 fn tee_handles_param_expansion() {
968 assert!(tee_redirect_rewrite("echo ${X:->f}").is_none());
970 assert!(tee_redirect_rewrite("echo ${X/>/_}").is_none());
971 assert_eq!(
973 tee_redirect_rewrite("make > ${LOG}/out.log").unwrap(),
974 "set -o pipefail; { make ; } | tee -- ${LOG}/out.log"
975 );
976 assert_eq!(
978 tee_redirect_rewrite("echo ${X/>/_} > out.log").unwrap(),
979 "set -o pipefail; { echo ${X/>/_} ; } | tee -- out.log"
980 );
981 }
982
983 #[test]
984 fn tee_rewrite_round_trips_through_build_response_b64() {
985 let resp = build_response(&payload("task package > build.log 2>&1", "toolu_t"));
986 let wrapped = resp["hookSpecificOutput"]["updatedInput"]["command"]
987 .as_str()
988 .unwrap();
989 let b64 = wrapped.split("--b64-cmd=").nth(1).expect("b64 arg");
990 let decoded =
991 String::from_utf8(URL_SAFE_NO_PAD.decode(b64.as_bytes()).unwrap()).unwrap();
992 assert_eq!(
993 decoded,
994 "set -o pipefail; { task package ; } 2>&1 | tee -- build.log"
995 );
996 assert!(decoded.starts_with("set -o pipefail;"));
998 }
999
1000 #[test]
1001 fn tee_non_redirect_command_encodes_verbatim() {
1002 let resp = build_response(&payload("echo hi", "toolu_a"));
1004 let wrapped = resp["hookSpecificOutput"]["updatedInput"]["command"]
1005 .as_str()
1006 .unwrap();
1007 let b64 = wrapped.split("--b64-cmd=").nth(1).unwrap();
1008 let decoded =
1009 String::from_utf8(URL_SAFE_NO_PAD.decode(b64.as_bytes()).unwrap()).unwrap();
1010 assert_eq!(decoded, "echo hi");
1011 }
1012}