Skip to main content

agentmux_bashwrap/
hook.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `hook` subcommand.
5//!
6//! Reads a PreToolUse JSON event on stdin (the contract Claude Code
7//! uses for hooks: see `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md`
8//! §5). If the event is a Bash invocation whose `command` hasn't
9//! already been wrapped, emit an `updatedInput.command` that invokes
10//! `agentmux-bashwrap exec` with the original command base64-encoded
11//! into argv. Idempotent — if the command is already wrapped, pass
12//! through with no rewrite.
13//!
14//! All errors degrade to a pass-through response so a hook failure
15//! never blocks Claude's tool execution.
16
17use 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/// Subset of the PreToolUse payload we care about. Extra fields are
25/// ignored (serde default).
26#[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    // Read entire stdin. Hook payloads are small.
42    let mut buf = String::new();
43    std::io::stdin().read_to_string(&mut buf)?;
44
45    // With AgentMux's wrapper as the CLI's shell prefix, the call is only
46    // recorded: the prefix process streams it, and the CLI keeps the call
47    // as the model made it. Otherwise (an older srv, `claude` run by hand
48    // in an agent's workspace) the command is rewritten as before.
49    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
60/// Build the hook response for a PreToolUse payload. Public for
61/// testability — callers in tests feed in a payload string and assert
62/// the response shape without going through stdin.
63pub 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            // Malformed hook input → pass through. Claude proceeds
68            // with native Bash, no streaming for this call.
69            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    // Idempotence: if the command already invokes our wrapper, don't
84    // re-wrap. Defends against weird hook re-fire scenarios.
85    if command.starts_with(WRAPPED_PREFIX) {
86        return passthrough();
87    }
88
89    // Tee a trailing file redirect back through the PTY so the agent's tool
90    // feed shows the output live while the file is still written. Conservative:
91    // only unambiguous trailing redirects are rewritten; anything else is
92    // encoded verbatim (today's behavior). See
93    // SPEC_TOOL_OUTPUT_TEE_AND_TERMINAL_RENDER_2026_06_17.md §3.
94    let effective_command =
95        tee_redirect_rewrite(command).unwrap_or_else(|| command.to_string());
96
97    // Issue #2491: the CLI's own `run_in_background` declaration is already
98    // present in `tool_input` right alongside `command` — thread it through
99    // so `bash_wrap.rs::effective_idle_timeout` can exempt a declared
100    // long-runner from the idle-kill safety net instead of that signal
101    // being dropped here and rediscovered from scratch at every consuming
102    // layer (the same duplication trap `docs/retro/retro-stuck-background-
103    // dock-timer-2026-08-10.md` already documented for the frontend's own
104    // `isAcceptedBackgroundLaunch` classifier).
105    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    // `updatedInput` replaces the tool's whole input; the CLI doesn't merge
121    // it. So every other field the model set (`description`, `timeout`,
122    // `run_in_background`, ...) is copied through, and only `command`
123    // changes. Rebuilt from `command` alone, the CLI ran every background
124    // call in the foreground, cut every command at its 120 s default, and
125    // labelled tasks with the wrapper
126    // (docs/retro/RETRO_BASHWRAP_HOOK_DROPS_BASH_TOOL_FIELDS_2026_10_10.md).
127    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
142/// The hook's response when the CLI's shell prefix is this wrapper: record
143/// the Bash call for the prefix process to claim (prefix.rs), and answer
144/// nothing, so the CLI runs the call exactly as the model made it, its
145/// permission rules and checks included. A call that can't be recorded still
146/// runs; it just isn't streamed.
147/// docs/specs/SPEC_BASH_STREAMING_VIA_SHELL_PREFIX_2026_10_10.md §2.2.
148pub 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    // Empty object → Claude Code treats as "no opinion", proceeds.
176    json!({})
177}
178
179// ─────────────────────────────────────────────────────────────────────────────
180// Trailing-redirect → `tee` rewrite (Feature 1)
181//
182// A command like `task package > build.log 2>&1` sends the inner shell's stdout
183// to the file, so the PTY (hence the tool-chunk stream, hence the feed) sees
184// nothing. We recognize an unambiguous *trailing* file redirect and rewrite it
185// so the same bytes still reach the file AND flow through the PTY via `tee`:
186//
187//   CMD > F            → set -o pipefail; { CMD ; } | tee -- F
188//   CMD >> F           → set -o pipefail; { CMD ; } | tee -a -- F
189//   CMD > F 2>&1       → set -o pipefail; { CMD ; } 2>&1 | tee -- F
190//   CMD >> F 2>&1      → set -o pipefail; { CMD ; } 2>&1 | tee -a -- F
191//   CMD &> F           → set -o pipefail; { CMD ; } 2>&1 | tee -- F
192//   CMD &>> F          → set -o pipefail; { CMD ; } 2>&1 | tee -a -- F
193//
194// `set -o pipefail` makes the pipeline's exit code reflect CMD (not `tee`), so
195// bashwrap still mirrors the real exit code. Wrapping the whole command in a
196// `{ ; }` group is safe for a single command OR a pipeline (a pipeline's stdout
197// is its last stage's stdout — exactly what the trailing `>` redirected), which
198// is why we bail on list operators (`&&`/`||`/`;`/`&`), where the redirect would
199// bind to only one command of the list.
200//
201// EVERYTHING ambiguous bails (returns None → encode verbatim, today's behavior):
202// quoted/comment/heredoc/process-substitution redirects, `/dev/null`, fd-specific
203// or dup redirects (`2>f`, `>&2`, and the `2>&1 > F` order), more than one output
204// redirect, and non-trailing redirects.
205// ─────────────────────────────────────────────────────────────────────────────
206
207#[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
223/// Rewrite a recognized trailing redirect to a `tee` pipeline, or `None` to pass
224/// the command through unchanged.
225pub(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
251/// Strip one layer of matching surrounding quotes for the `/dev/null` check.
252fn 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
264/// Quote/group-aware scan for a single top-level trailing output redirect.
265/// Returns None on any bail condition (see the module comment above).
266fn 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; // `${...}` parameter-expansion nesting
275
276    let mut out_redirs: Vec<(OutKind, usize, usize)> = Vec::new();
277    let mut dups: Vec<(usize, usize)> = Vec::new(); // 2>&1 occurrences (start, end)
278    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        // Unquoted. Quote/escape/group/comment first.
309        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                // Comment iff at a word boundary; otherwise a literal `#`.
331                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                // `${...}` parameter expansion: a literal `>`/`<`/`|` inside it
352                // (e.g. `${X:->f}`, `${X/>/_}`) must not be read as an operator.
353                // `$(...)` is handled by paren_depth via the `(` below.
354                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        // Inside a subshell / $(...) / ${...} group: ignore operators (the
372        // redirect there doesn't bind the outer command), but keep tracking
373        // quotes (above).
374        if paren_depth > 0 || brace_depth > 0 {
375            i += 1;
376            continue;
377        }
378        // Depth 0, unquoted — operator recognition.
379        match c {
380            b'<' => {
381                if i + 1 < n && (b[i + 1] == b'(' || b[i + 1] == b'<') {
382                    return None; // process substitution or heredoc
383                }
384                other_redir = true; // input redirect
385                i += 1;
386            }
387            b'>' => {
388                if i + 1 < n && b[i + 1] == b'(' {
389                    return None; // process substitution >(
390                }
391                if i > 0 && b[i - 1].is_ascii_digit() {
392                    // fd-prefixed: recognize exactly `2>&1`, else bail.
393                    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; // >&N dup
405                    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; // standalone & (background)
425                    i += 1;
426                }
427            }
428            b'|' => {
429                // Both `||` and a plain pipe bail. A plain pipe is bailed (not
430                // wrapped) because `set -o pipefail` on `{ a | b ; } | tee` would
431                // make the INNER pipeline report its first failure instead of the
432                // last stage's exit — changing the exit code the original
433                // `a | b > f` reported. Single commands have no inner pipe, so
434                // pipefail correctly surfaces their exit through the tee.
435                list_op = true;
436                i += if i + 1 < n && b[i + 1] == b'|' { 2 } else { 1 };
437            }
438            // `;` and an unquoted/unescaped newline both separate commands. A
439            // multi-line command whose LAST line has a trailing redirect must not
440            // be wrapped, or `{ <all lines> ; } | tee` would tee the earlier
441            // lines' stdout into the file too (violating the "same bytes to FILE"
442            // guarantee). Quoted/heredoc/escaped newlines never reach here.
443            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; // unterminated quote
454    }
455    if list_op || other_redir || out_redirs.len() != 1 {
456        return None;
457    }
458    let (kind, rstart, rend) = out_redirs[0];
459    // No dup may precede the redirect (the `2>&1 > F` order has different
460    // semantics; bail rather than mis-tee it).
461    if dups.iter().any(|(ds, _)| *ds < rstart) {
462        return None;
463    }
464    // Target word immediately after the redirect.
465    let (tstart, tend) = read_word(b, rend)?;
466    // After the target: an optional trailing `2>&1`, then EOF.
467    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; // trailing junk (incl. a second dup)
475    }
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
492/// Read one shell word (quote-aware) starting at/after `from`, skipping leading
493/// spaces. Returns the word's byte range, or None if there's no word.
494fn 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            // operator chars terminate a bare word
537            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
549/// Quote a value for embedding in a shell argv position.
550///
551/// The tool-use-id is provider-generated and currently always opaque
552/// ASCII, but be defensive: if it contains a non-safe char, escape
553/// per the host shell's rules.
554///
555/// - **Unix shells** (bash/sh/zsh): single-quote wrap with `'\''`
556///   escape for embedded single quotes.
557/// - **Windows `cmd.exe /C`**: cmd doesn't interpret single quotes
558///   as quoting; we use double quotes and escape embedded `"` per
559///   cmd.exe rules. This is the shell Claude Code's Bash tool
560///   invokes on Win32.
561///
562/// Pure-alphanumeric ids return unchanged on every platform.
563fn 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        // cmd.exe: double-quote wrap, escape `"` as `""`. Reasonable
570        // approximation — full cmd.exe quoting is famously gnarly,
571        // but for opaque-ID-with-weird-char defense this is enough.
572        let escaped = s.replace('"', "\"\"");
573        format!("\"{}\"", escaped)
574    }
575    #[cfg(not(windows))]
576    {
577        // bash/sh/zsh: single-quote wrap, escape `'` as `'\''`.
578        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        // base64("echo hi") = ZWNobyBoaQ
608        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        // The wrapped form must round-trip — pull the b64 back and decode.
624        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        // Codex P2 round 3: the previous name `passes_through_when_
672        // command_field_missing` lied about behavior, and the inline
673        // comment claimed `bash -c ""` would "fail at wrapper start
674        // time" — which is wrong; an empty bash command exits 0
675        // silently. Renamed + comment corrected to describe what
676        // actually happens.
677        //
678        // Behavior under test: when `tool_input.command` is missing,
679        // we still emit a rewrite (with `--b64-cmd=` of an empty
680        // string). The wrapper then runs `bash -c ""` which exits 0
681        // immediately. Net effect: a no-op Bash call streams a "0
682        // chars" system chunk + an empty body, and Claude sees an
683        // empty tool_result. Not catastrophic; the empty input was
684        // already going to do nothing.
685        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        // The b64 of an empty string is the empty string, so we
696        // expect `--b64-cmd=` immediately followed by whatever ends
697        // the command (whitespace or EOL). Match the prefix.
698        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    /// `updatedInput` replaces the whole input, so every field but `command`
750    /// must come back as the model set it: the CLI reads `run_in_background`
751    /// and `timeout` from it, and labels the call and its task with
752    /// `description`.
753    #[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    /// Prefix mode: the call is recorded for the prefix process and the
780    /// response changes nothing (no `updatedInput`, no `permissionDecision`).
781    #[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        // Standard bash idiom: `'` → `'\''` (close, escaped, reopen).
851        // For `o'malley` the result is `'o'\''malley'`.
852        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        // cmd.exe `""` is the escape for a literal `"` inside a
867        // double-quoted argument. For `a"b` the result is `"a""b"`.
868        let q = shell_quote("a\"b");
869        assert_eq!(q, "\"a\"\"b\"");
870    }
871
872    // ── tee redirect rewrite (Feature 1) ──────────────────────────────
873
874    #[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        // A pipe inside the command bails: `set -o pipefail` on the tee wrapper
905        // would change the inner pipeline's exit-code semantics vs the original.
906        assert!(tee_redirect_rewrite("cargo build | grep error > errors.log").is_none());
907    }
908
909    #[test]
910    fn tee_bails_on_multiline_command() {
911        // A multi-line command with a trailing redirect on the last line must NOT
912        // be wrapped — wrapping would tee the earlier lines' output into the file.
913        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        // A backslash-newline line continuation is NOT a separator — still rewritten.
916        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()); // quoted >
948        assert!(tee_redirect_rewrite("echo \"x > y\"").is_none()); // quoted >
949        assert!(tee_redirect_rewrite("make # writes > log").is_none()); // comment
950        assert!(tee_redirect_rewrite("cat <<EOF > f\nhi\nEOF").is_none()); // heredoc
951        assert!(tee_redirect_rewrite("diff <(a) > f").is_none()); // process subst
952    }
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()); // not trailing
957        assert!(tee_redirect_rewrite("cmd > f && other").is_none()); // list op
958        assert!(tee_redirect_rewrite("a > f1 > f2").is_none()); // two redirects
959        assert!(tee_redirect_rewrite("cmd 2> err.log").is_none()); // fd-specific
960        assert!(tee_redirect_rewrite("cmd >&2").is_none()); // dup
961        assert!(tee_redirect_rewrite("cmd 2>&1 > f").is_none()); // leading 2>&1 order
962        assert!(tee_redirect_rewrite("echo hi").is_none()); // no redirect
963        assert!(tee_redirect_rewrite("server > log &").is_none()); // background
964    }
965
966    #[test]
967    fn tee_handles_param_expansion() {
968        // A literal `>` inside a ${...} expansion is NOT a redirect → no rewrite.
969        assert!(tee_redirect_rewrite("echo ${X:->f}").is_none());
970        assert!(tee_redirect_rewrite("echo ${X/>/_}").is_none());
971        // A real trailing redirect to a ${...}-bearing target IS rewritten.
972        assert_eq!(
973            tee_redirect_rewrite("make > ${LOG}/out.log").unwrap(),
974            "set -o pipefail; { make ; } | tee -- ${LOG}/out.log"
975        );
976        // ...and a ${...} with an inner `>` plus a real trailing redirect.
977        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        // pipefail present → pipeline exit code reflects the command, not tee.
997        assert!(decoded.starts_with("set -o pipefail;"));
998    }
999
1000    #[test]
1001    fn tee_non_redirect_command_encodes_verbatim() {
1002        // No recognized redirect → command encodes unchanged (today's behavior).
1003        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}