Skip to main content

rhema_agent_tools/
shell.rs

1//! The built-in tool: one approval-gated shell, scoped to a workspace.
2//!
3//! [`ShellTools`] is one [`ToolResolver`], not a registry: the same spec
4//! goes to the model every step. `bash` runs its command with `bash -c`
5//! and the workspace root as working directory — reading, listing, and
6//! editing are shell work, not separate tools. The output streams are
7//! capped and the command killed after a timeout; the exit code rides on
8//! the result for the UI. Every call consults the approval hook installed
9//! for the current turn; tool resolution happens synchronously inside a
10//! turn, so a per-turn swap of that hook is race-free. No hook installed
11//! means calls are rejected — the fail-safe default.
12//!
13//! Where the command actually runs is the [`ShellExecutor`] seam
14//! ([`crate::exec`]): this host's `bash -c` child processes by default, a
15//! remote sandbox when a frontend brings one. The spec, the gate, the
16//! caps, and the error shaping stay here, so a command's tool result
17//! reads the same from either side.
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex, PoisonError};
23use std::time::Duration;
24
25use async_trait::async_trait;
26use serde_json::{Value, json};
27
28use rhema_core::{
29    OutputOnly, ToolCall, ToolContext, ToolProgress, ToolResolver, ToolResult, ToolSpec,
30};
31
32use crate::approval::Approval;
33use crate::exec::{Executed, ShellError, ShellExecutor};
34
35/// Command output streams are each cut at this size — in bytes and in
36/// lines, whichever is hit first — keeping the tail: errors and final
37/// results live at the end of a command's output.
38const BASH_OUTPUT_LIMIT: usize = 50 * 1024;
39const BASH_OUTPUT_LINES: usize = 2000;
40/// A command is killed after this long.
41const BASH_TIMEOUT: Duration = Duration::from_secs(120);
42
43/// The built-in tool set: `bash`. Shared behind an `Arc` between the
44/// engine's `Agent` and the driver.
45pub struct ShellTools {
46    /// Working directory every command starts in.
47    workspace: PathBuf,
48    /// Where commands run: this host's processes by default, a remote
49    /// host when the frontend installed one.
50    exec: Arc<dyn ShellExecutor>,
51    /// The staging directory of a wired [`crate::vision`] macro: after
52    /// each command, images staged under it attach to the result. The
53    /// env map carries the path (`RHEMA_VISION_STAGING`), so a
54    /// frontend's wiring lights the inbox up through [`Self::with_env`]
55    /// alone.
56    vision_inbox: Option<PathBuf>,
57    /// The approval hook installed for the current turn; `None` rejects
58    /// calls.
59    pending: Mutex<Option<Arc<dyn Approval>>>,
60}
61
62impl ShellTools {
63    /// The specs the built-in tool set advertises. Fixed at compile time
64    /// and independent of the instance: the system-prompt renderer names
65    /// the same tools the resolver offers, so the prompt and the API's
66    /// tools array cannot drift.
67    pub fn builtin_specs() -> Vec<ToolSpec> {
68        vec![ToolSpec::new(
69            "bash",
70            "Run a shell command (`bash -c`) with the workspace root as \
71             working directory. Reading, listing, and editing files are \
72             shell work. Output is capped and the exit status reported; a \
73             non-zero exit comes back as an error. Requires user approval.",
74            json!({
75                "type": "object",
76                "properties": {
77                    "command": {
78                        "type": "string",
79                        "description": "The shell command to run."
80                    }
81                },
82                "required": ["command"]
83            }),
84        )]
85    }
86
87    /// Tools rooted at `workspace`, commands running on this host; the
88    /// path is canonicalized, falling back to the input if
89    /// canonicalization fails (canonicalization requires every component
90    /// to exist). A build without the `local-shell` feature installs the
91    /// no-shell executor: every command fails in-band until a real one is
92    /// installed.
93    pub fn new(workspace: impl Into<PathBuf>) -> Self {
94        Self::with_executor(workspace, crate::exec::default_executor())
95    }
96
97    /// Tools rooted at `workspace` whose commands run with `env`
98    /// layered over the inherited environment — how a frontend hands
99    /// its agent's tools a connection (a daemon URL, a scoped token, a
100    /// PATH addition) without touching the process environment. A wired
101    /// [`crate::vision`] macro rides the same map: its staging
102    /// directory's var lights the inbox up. A build without the
103    /// `local-shell` feature installs the no-shell executor either way:
104    /// the env has nothing to ride on.
105    pub fn with_env(workspace: impl Into<PathBuf>, env: &BTreeMap<String, String>) -> Self {
106        Self::with_executor(workspace, crate::exec::default_executor_with(env))
107            .with_vision_inbox(env.get(crate::vision::VAR_STAGING).map(PathBuf::from))
108    }
109
110    /// Tools rooted at `workspace` whose commands run on `exec` — the
111    /// seam a remote-shell frontend comes in through.
112    pub fn with_executor(workspace: impl Into<PathBuf>, exec: Arc<dyn ShellExecutor>) -> Self {
113        Self {
114            workspace: canonicalized(workspace.into()),
115            exec,
116            vision_inbox: None,
117            pending: Mutex::new(None),
118        }
119    }
120
121    /// Lights the vision inbox up explicitly — the seam a resolver that
122    /// runs its own inner `ShellTools` (the subagents extension) uses to
123    /// inherit its parent's inbox, since it builds on
124    /// [`Self::with_executor`], which reads no env map.
125    pub fn with_vision_inbox(mut self, inbox: Option<PathBuf>) -> Self {
126        self.vision_inbox = inbox;
127        self
128    }
129
130    /// The staging directory images attach from, when a frontend wired
131    /// the [`crate::vision`] macro for this shell.
132    pub fn vision_inbox(&self) -> Option<&Path> {
133        self.vision_inbox.as_deref()
134    }
135
136    /// The working directory every command starts in.
137    pub fn workspace(&self) -> &Path {
138        &self.workspace
139    }
140
141    /// The executor commands run on. A resolver that runs its own inner
142    /// `ShellTools` — the subagents extension — builds the inner one on
143    /// the same executor, so subagents land where the turn's commands
144    /// land.
145    pub fn executor(&self) -> Arc<dyn ShellExecutor> {
146        self.exec.clone()
147    }
148
149    /// Installs the approval hook consulted by the tool. Call once per
150    /// turn before stepping; tool resolution cannot overlap another
151    /// turn's hook, so the swap is safe.
152    pub fn install_approval(&self, approval: Arc<dyn Approval>) {
153        *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = Some(approval);
154    }
155
156    /// The approval hook installed for the current turn, if any. A
157    /// resolver that runs its own inner `ShellTools` — the subagents
158    /// extension — reads this to inherit the turn's policy rather than
159    /// holding a hook of its own.
160    pub fn current_approval(&self) -> Option<Arc<dyn Approval>> {
161        self.pending
162            .lock()
163            .unwrap_or_else(PoisonError::into_inner)
164            .clone()
165    }
166
167    /// Removes the approval hook; calls reject until one is installed
168    /// again.
169    pub fn clear_approval(&self) {
170        *self.pending.lock().unwrap_or_else(PoisonError::into_inner) = None;
171    }
172
173    /// `None` to proceed, `Some` a rejection result to return instead.
174    fn approval_gate(&self, call: &ToolCall) -> Option<ToolResult> {
175        let approved = self
176            .pending
177            .lock()
178            .unwrap_or_else(PoisonError::into_inner)
179            .as_ref()
180            .is_some_and(|approval| approval.approve(call));
181        if approved {
182            None
183        } else {
184            Some(ToolResult::failure(&call.id, "rejected by user approval"))
185        }
186    }
187
188    async fn bash(
189        &self,
190        call: ToolCall,
191        cancelled: &AtomicBool,
192        progress: &mut dyn ToolProgress,
193    ) -> ToolResult {
194        if let Some(rejection) = self.approval_gate(&call) {
195            return rejection;
196        }
197        let Some(command) = arg_str(&call.args, "command") else {
198            return ToolResult::failure(call.id, "missing required argument: command");
199        };
200        // The executor's seam speaks raw output chunks; the status half of
201        // the progress channel has no command-side counterpart.
202        match self
203            .exec
204            .run(
205                &command,
206                &self.workspace,
207                BASH_TIMEOUT,
208                cancelled,
209                &mut |chunk: &str| progress.output(chunk),
210            )
211            .await
212        {
213            Ok(executed) => {
214                let mut result = finished(call.id, executed);
215                if let Some(staging) = &self.vision_inbox {
216                    crate::vision::attach(staging, &mut result);
217                }
218                result
219            }
220            Err(ShellError::Timeout(secs)) => ToolResult::failure(
221                call.id,
222                format!("command timed out after {secs}s and was killed"),
223            ),
224            Err(ShellError::Cancelled) => ToolResult::failure(call.id, "cancelled"),
225            Err(err) => ToolResult::failure(call.id, err.to_string()),
226        }
227    }
228}
229
230/// The tool result for a command that reached an exit: the output, the
231/// success flag from the code, and the code itself for the UI.
232fn finished(id: String, executed: Executed) -> ToolResult {
233    let content = format_command_output(executed.code, &executed.stdout, &executed.stderr);
234    let result = if executed.code == Some(0) {
235        ToolResult::success(id, content)
236    } else {
237        ToolResult::failure(id, content)
238    };
239    match executed.code {
240        Some(code) => result.with_code(code),
241        None => result,
242    }
243}
244
245#[async_trait]
246impl ToolResolver for ShellTools {
247    fn specs(&self) -> Vec<ToolSpec> {
248        Self::builtin_specs()
249    }
250
251    async fn resolve(&self, call: ToolCall, ctx: &ToolContext) -> ToolResult {
252        // A fn item, not a closure: its lifetime-generic signature is
253        // what the higher-ranked progress bound needs.
254        self.resolve_streaming(call, ctx, &mut OutputOnly(silent))
255            .await
256    }
257
258    async fn resolve_streaming(
259        &self,
260        call: ToolCall,
261        ctx: &ToolContext,
262        progress: &mut dyn ToolProgress,
263    ) -> ToolResult {
264        if ctx.cancelled.load(Ordering::Relaxed) {
265            return ToolResult::failure(call.id, "cancelled");
266        }
267        // The name is cloned out because the match arms move `call`.
268        let name = call.name.clone();
269        match name.as_str() {
270            "bash" => self.bash(call, &ctx.cancelled, progress).await,
271            other => ToolResult::failure(call.id, format!("unknown tool: {other}")),
272        }
273    }
274}
275
276fn arg_str(args: &Value, key: &str) -> Option<String> {
277    args.get(key).and_then(Value::as_str).map(str::to_owned)
278}
279
280/// The no-op progress: the plain `resolve` path runs the same streaming
281/// code with nobody listening.
282fn silent(_: &str) {}
283
284/// Canonicalizes `path`, falling back to the input when it does not exist
285/// yet.
286fn canonicalized(path: PathBuf) -> PathBuf {
287    std::fs::canonicalize(&path).unwrap_or(path)
288}
289
290/// Lossy text of `bytes`, tail-kept at `limit` bytes and `max_lines`
291/// lines (whichever is hit first): the elided head is counted in a
292/// marker, and the byte cut lands on a line start so the first kept row
293/// is whole. A single line longer than the whole byte budget is kept
294/// partially, past the marker.
295fn render_truncated(bytes: &[u8], limit: usize, max_lines: usize) -> String {
296    let line_count = bytes.iter().filter(|&&byte| byte == b'\n').count();
297    if bytes.len() <= limit && line_count <= max_lines {
298        return String::from_utf8_lossy(bytes).into_owned();
299    }
300    let byte_cut = bytes.len().saturating_sub(limit);
301    // No newline in the elided head means the whole output is one
302    // line longer than the budget: cut at the raw byte boundary so
303    // only the tail rides through, past the marker.
304    let byte_cut = match bytes[..byte_cut].iter().rposition(|&byte| byte == b'\n') {
305        Some(pos) => pos + 1,
306        None => byte_cut,
307    };
308    // The line cut: the start of the row that begins the last
309    // `max_lines` rows.
310    let mut line_cut = 0usize;
311    let mut seen = 0usize;
312    for (index, &byte) in bytes.iter().enumerate().rev() {
313        if byte == b'\n' {
314            seen += 1;
315            if seen == max_lines {
316                line_cut = index + 1;
317                break;
318            }
319        }
320    }
321    let cut = byte_cut.max(line_cut);
322    let mut text = format!("[output truncated — {} bytes elided]\n", cut);
323    text.push_str(&String::from_utf8_lossy(&bytes[cut..]));
324    text
325}
326
327fn format_command_output(code: Option<i32>, stdout: &[u8], stderr: &[u8]) -> String {
328    let code = match code {
329        Some(code) => format!("exit code {code}"),
330        None => "terminated by signal".to_string(),
331    };
332    format!(
333        "{code}\n--- stdout ---\n{}\n--- stderr ---\n{}",
334        render_truncated(stdout, BASH_OUTPUT_LIMIT, BASH_OUTPUT_LINES),
335        render_truncated(stderr, BASH_OUTPUT_LIMIT, BASH_OUTPUT_LINES),
336    )
337}
338
339#[cfg(test)]
340mod truncation_tests {
341    use super::*;
342
343    #[test]
344    fn truncation_keeps_the_tail_and_counts_the_elided_head() {
345        let bytes = b"head line\nerror: the real failure\n".to_vec();
346        let text = render_truncated(&bytes, 10, 2000);
347        assert!(text.starts_with("[output truncated"), "{text}");
348        assert!(text.contains("error: the real failure"), "{text}");
349        // The cut lands on a line start: the first kept row is whole.
350        assert!(!text.contains("head line"), "{text}");
351    }
352
353    #[test]
354    fn a_single_line_longer_than_the_budget_is_kept_partially() {
355        // Newline-free output: `yes | tr -d '\n'` shape. The whole
356        // buffer must not ride through under a "0 bytes elided"
357        // marker; the doc promises a partial keep past the marker.
358        let bytes = vec![b'a'; 100_000];
359        let text = render_truncated(&bytes, 1024, 2000);
360        assert!(
361            text.starts_with("[output truncated — 98976 bytes elided]"),
362            "{text}"
363        );
364        let kept = text.split_once('\n').map(|(_, tail)| tail).unwrap_or("");
365        assert_eq!(kept.len(), 1024, "kept tail: {} bytes", kept.len());
366    }
367
368    #[test]
369    fn the_line_limit_cuts_more_rows_than_the_byte_limit() {
370        let bytes = (0..3000).map(|i| format!("row {i}\n")).collect::<String>();
371        let text = render_truncated(bytes.as_bytes(), 50 * 1024, 2000);
372        // The last 2000 rows minus one: the cut lands after row 1000's
373        // newline, so rows 1001..2999 survive.
374        assert!(text.starts_with("[output truncated — "), "{text}");
375        assert!(text.contains("row 1001\n"), "{text}");
376        assert!(text.contains("row 2999\n"), "{text}");
377        assert!(!text.contains("row 1000\n"), "{text}");
378        assert!(!text.contains("row 999\n"), "{text}");
379    }
380
381    #[test]
382    fn output_within_the_limits_passes_untouched() {
383        assert_eq!(render_truncated(b"clean\n", 10, 2000), "clean\n");
384    }
385
386    #[test]
387    fn a_signalled_command_reports_termination_not_an_exit_code() {
388        let text = format_command_output(None, b"partial\n", b"");
389        assert!(text.starts_with("terminated by signal"), "{text}");
390    }
391}
392
393#[cfg(test)]
394mod executor_tests {
395    use super::*;
396    use crate::approval::AllowAll;
397    use serde_json::json;
398    use std::sync::Mutex;
399
400    /// An executor that answers from a script, recording the commands it
401    /// was handed: the delegation tests run without spawning anything.
402    struct Scripted {
403        outcome: Result<Executed, ShellError>,
404        commands: Mutex<Vec<String>>,
405        streamed: Mutex<Vec<String>>,
406    }
407
408    impl Scripted {
409        fn ok(stdout: &str) -> Self {
410            Self {
411                outcome: Ok(Executed {
412                    stdout: stdout.as_bytes().to_vec(),
413                    stderr: Vec::new(),
414                    code: Some(0),
415                }),
416                commands: Mutex::new(Vec::new()),
417                streamed: Mutex::new(Vec::new()),
418            }
419        }
420    }
421
422    #[async_trait]
423    impl ShellExecutor for Scripted {
424        async fn run(
425            &self,
426            command: &str,
427            _cwd: &Path,
428            _timeout: Duration,
429            _cancelled: &AtomicBool,
430            progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
431        ) -> Result<Executed, ShellError> {
432            self.commands
433                .lock()
434                .unwrap_or_else(PoisonError::into_inner)
435                .push(command.to_owned());
436            progress("streamed line\n");
437            self.streamed
438                .lock()
439                .unwrap_or_else(PoisonError::into_inner)
440                .push("streamed line\n".to_owned());
441            self.outcome.clone()
442        }
443    }
444
445    fn call(command: &str) -> ToolCall {
446        ToolCall {
447            id: "call_1".into(),
448            name: "bash".into(),
449            args: json!({ "command": command }),
450            provider_data: Value::Null,
451        }
452    }
453
454    fn streamed(executor: &Scripted) -> Vec<String> {
455        executor
456            .streamed
457            .lock()
458            .unwrap_or_else(PoisonError::into_inner)
459            .clone()
460    }
461
462    #[tokio::test]
463    async fn a_result_streams_progress_and_carries_the_output() {
464        let executor = Arc::new(Scripted::ok("out\n"));
465        let tools = ShellTools::with_executor("/tmp", executor.clone());
466        tools.install_approval(Arc::new(AllowAll));
467        let mut progress = OutputOnly(silent);
468        let result = tools
469            .resolve_streaming(call("echo hi"), &ToolContext::default(), &mut progress)
470            .await;
471        assert!(!result.is_error);
472        assert_eq!(result.code, Some(0));
473        assert!(
474            result.content.contains("--- stdout ---\nout\n"),
475            "{result:?}"
476        );
477        // The command reached the executor verbatim, and the executor's
478        // progress rode through to the resolver's caller.
479        assert_eq!(executor.commands.lock().unwrap().as_slice(), ["echo hi"]);
480        assert_eq!(streamed(&executor), vec!["streamed line\n"]);
481    }
482
483    #[tokio::test]
484    async fn every_shell_error_lands_as_an_in_band_failure() {
485        let cases: Vec<(ShellError, &str)> = vec![
486            (
487                ShellError::Timeout(120),
488                "command timed out after 120s and was killed",
489            ),
490            (ShellError::Cancelled, "cancelled"),
491            (
492                ShellError::Failed("host unreachable".into()),
493                "failed to run bash: host unreachable",
494            ),
495        ];
496        for (outcome, expected) in cases {
497            let executor = Arc::new(Scripted {
498                outcome: Err(outcome),
499                commands: Mutex::new(Vec::new()),
500                streamed: Mutex::new(Vec::new()),
501            });
502            let tools = ShellTools::with_executor("/tmp", executor);
503            tools.install_approval(Arc::new(AllowAll));
504            let result = tools.resolve(call("x"), &ToolContext::default()).await;
505            assert!(result.is_error, "{expected}");
506            assert_eq!(result.content, expected);
507        }
508    }
509
510    #[tokio::test]
511    async fn a_non_zero_exit_is_an_error_result_with_the_code() {
512        let executor = Arc::new(Scripted {
513            outcome: Ok(Executed {
514                stdout: b"boom\n".to_vec(),
515                stderr: b"trace\n".to_vec(),
516                code: Some(3),
517            }),
518            commands: Mutex::new(Vec::new()),
519            streamed: Mutex::new(Vec::new()),
520        });
521        let tools = ShellTools::with_executor("/tmp", executor);
522        tools.install_approval(Arc::new(AllowAll));
523        let result = tools.resolve(call("exit 3"), &ToolContext::default()).await;
524        assert!(result.is_error);
525        assert_eq!(result.code, Some(3));
526        assert!(result.content.contains("exit code 3"));
527        assert!(result.content.contains("--- stderr ---\ntrace\n"));
528    }
529
530    #[tokio::test]
531    async fn the_inner_shell_shares_the_executor_of_its_parent() {
532        // The subagents extension builds its inner shell off this
533        // accessor; the command must land where the parent's do.
534        let executor = Arc::new(Scripted::ok(""));
535        let tools = ShellTools::with_executor("/tmp", executor);
536        let inner = ShellTools::with_executor(tools.workspace().to_path_buf(), tools.executor());
537        assert_eq!(inner.workspace(), tools.workspace());
538        assert!(Arc::ptr_eq(&inner.executor(), &tools.executor()));
539    }
540
541    #[tokio::test]
542    async fn staged_images_attach_to_the_command_that_staged_them() {
543        let staging = tempfile::tempdir().unwrap();
544        // What the `see` function leaves behind: one viewable image,
545        // one file the sniffer must refuse.
546        std::fs::write(staging.path().join("1.img"), b"\x89PNG\r\n\x1a\nrest").unwrap();
547        std::fs::write(staging.path().join("2.img"), b"not an image").unwrap();
548        let mut env = BTreeMap::new();
549        env.insert(
550            crate::vision::VAR_STAGING.to_owned(),
551            staging.path().display().to_string(),
552        );
553        let tools = ShellTools::with_env("/tmp", &env);
554        tools.install_approval(Arc::new(AllowAll));
555        let result = tools.resolve(call("see x"), &ToolContext::default()).await;
556
557        assert_eq!(result.images.len(), 1, "{}", result.content);
558        assert_eq!(result.images[0].media_type, rhema_core::ImageMediaType::Png);
559        assert!(
560            result
561                .content
562                .contains("1 image(s) attached and visible to you"),
563            "{}",
564            result.content
565        );
566        assert!(
567            result.content.contains("not a recognized image"),
568            "{}",
569            result.content
570        );
571        assert!(tools.vision_inbox().is_some());
572        assert!(
573            std::fs::read_dir(staging.path()).unwrap().next().is_none(),
574            "the staging dir drained"
575        );
576
577        // Without the var, no inbox: staged files stay untouched.
578        let tools = ShellTools::with_env("/tmp", &BTreeMap::new());
579        assert_eq!(tools.vision_inbox(), None);
580    }
581}
582
583#[cfg(all(test, feature = "local-shell"))]
584mod local_process_tests {
585    use super::*;
586    use crate::approval::AllowAll;
587    use serde_json::json;
588
589    fn call(command: &str) -> ToolCall {
590        ToolCall {
591            id: "call_1".into(),
592            name: "bash".into(),
593            args: json!({ "command": command }),
594            provider_data: Value::Null,
595        }
596    }
597
598    #[tokio::test]
599    async fn a_command_streams_its_lines_before_its_result() {
600        let tools = ShellTools::new(std::env::temp_dir());
601        tools.install_approval(Arc::new(AllowAll));
602        let streamed = Arc::new(Mutex::new(Vec::new()));
603        let collector = streamed.clone();
604        let mut progress = OutputOnly(move |chunk: &str| {
605            collector
606                .lock()
607                .unwrap_or_else(PoisonError::into_inner)
608                .push(chunk.to_owned());
609        });
610        let result = tools
611            .resolve_streaming(
612                call("printf 'one\\ntwo\\n'; echo three"),
613                &ToolContext::default(),
614                &mut progress,
615            )
616            .await;
617
618        // Every line rode the progress live, newline included.
619        assert_eq!(
620            *streamed.lock().unwrap_or_else(PoisonError::into_inner),
621            vec!["one\n".to_owned(), "two\n".to_owned(), "three\n".to_owned()]
622        );
623        // The result still carries the whole output, stdout and stderr
624        // in their own sections.
625        assert!(!result.is_error);
626        assert!(result.content.contains("--- stdout ---\n"));
627        assert!(result.content.contains("one\ntwo\nthree"));
628        tools.clear_approval();
629    }
630
631    #[tokio::test]
632    async fn stderr_streams_too_and_the_exit_code_rides_the_result() {
633        let tools = ShellTools::new(std::env::temp_dir());
634        tools.install_approval(Arc::new(AllowAll));
635        let streamed = Arc::new(Mutex::new(Vec::new()));
636        let collector = streamed.clone();
637        let mut progress = OutputOnly(move |chunk: &str| {
638            collector
639                .lock()
640                .unwrap_or_else(PoisonError::into_inner)
641                .push(chunk.to_owned());
642        });
643        let result = tools
644            .resolve_streaming(
645                call("echo oops >&2; exit 3"),
646                &ToolContext::default(),
647                &mut progress,
648            )
649            .await;
650
651        assert_eq!(
652            *streamed.lock().unwrap_or_else(PoisonError::into_inner),
653            vec!["oops\n".to_owned()]
654        );
655        assert!(result.is_error);
656        assert_eq!(result.code, Some(3));
657        assert!(result.content.contains("oops"));
658        tools.clear_approval();
659    }
660
661    #[tokio::test]
662    async fn a_cancelled_command_stops_mid_run() {
663        let tools = ShellTools::new(std::env::temp_dir());
664        tools.install_approval(Arc::new(AllowAll));
665        let ctx = ToolContext::default();
666        ctx.cancelled.store(true, Ordering::Relaxed);
667        let result = tools.resolve(call("sleep 30"), &ctx).await;
668        assert!(result.is_error);
669        assert_eq!(result.content, "cancelled");
670        tools.clear_approval();
671    }
672
673    #[test]
674    fn the_installed_hook_reads_back_until_cleared() {
675        struct Spy;
676        impl Approval for Spy {
677            fn approve(&self, _call: &ToolCall) -> bool {
678                true
679            }
680        }
681
682        let tools = ShellTools::new("/tmp");
683        assert!(
684            tools.current_approval().is_none(),
685            "no hook installed: none readable"
686        );
687        let hook: Arc<dyn Approval> = Arc::new(Spy);
688        tools.install_approval(hook.clone());
689        let read = tools.current_approval().unwrap();
690        // The same policy answers through the read handle.
691        let probe = ToolCall {
692            name: "bash".into(),
693            ..ToolCall::default()
694        };
695        assert!(read.approve(&probe));
696        tools.clear_approval();
697        assert!(tools.current_approval().is_none());
698    }
699}