Skip to main content

mobius/backend/sandbox/
mod.rs

1//! Sandboxed execution and its approval boundary.
2
3use std::collections::BTreeSet;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use serde::Deserialize;
8use serde::Serialize;
9
10use crate::BoxFuture;
11use crate::Error;
12use crate::Result;
13use crate::middleware::Middleware;
14use crate::middleware::PromptSection;
15use crate::middleware::RuntimeContext;
16use crate::middleware::SessionStartContext;
17use crate::middleware::SessionStartSource;
18use crate::middleware::manifest::MiddlewareManifest;
19use crate::middleware::manifest::MiddlewareSettingChoice;
20use crate::middleware::manifest::MiddlewareSettingChoices;
21use crate::middleware::manifest::MiddlewareSettingManifest;
22use crate::protocol::EventMsg;
23use crate::protocol::FrontendBlock;
24use crate::protocol::FrontendContribution;
25use crate::protocol::FrontendEvent;
26use crate::protocol::FrontendTone;
27use crate::protocol::ReviewDecision;
28use crate::protocol::ToolCall;
29
30mod approval;
31mod background;
32pub mod local;
33mod process_group;
34mod worker;
35pub use worker::{WorkerCommand, WorkerProcess};
36
37pub(crate) const MAX_FILE_BYTES: usize = 1024 * 1024;
38pub(crate) const MAX_BINARY_FILE_BYTES: usize = 50 * 1024 * 1024;
39
40mod text {
41    pub const APPROVAL_POLICY_ALLOW_DESCRIPTION: &str =
42        "Run approval-required actions without network access";
43    pub const APPROVAL_POLICY_ALLOW_LABEL: &str = "Allow · no network";
44    pub const APPROVAL_POLICY_ALLOW_NETWORK_DESCRIPTION: &str =
45        "Run approval-required actions with network access";
46    pub const APPROVAL_POLICY_ALLOW_NETWORK_LABEL: &str = "Allow · network";
47    pub const APPROVAL_POLICY_ASK_DESCRIPTION: &str =
48        "Pause approval-required actions for a human decision";
49    pub const APPROVAL_POLICY_ASK_LABEL: &str = "Ask";
50    pub const APPROVAL_POLICY_FULL_ACCESS_DESCRIPTION: &str = "Run file operations and shell commands with host filesystem and network access without approval";
51    pub const APPROVAL_POLICY_FULL_ACCESS_LABEL: &str = "Full access";
52    pub const DEFAULTS_APPROVAL_POLICY: &str = "ask";
53    pub const MANIFEST_DESCRIPTION: &str = "Control mutation approval and sandbox isolation";
54    pub const MANIFEST_LABEL: &str = "Sandbox";
55    pub const PROMPT_LINUX: &str = "möbius is running on Linux.";
56    pub const PROMPT_MACOS: &str = "möbius is running on macOS.";
57    pub const PROMPT_OTHER: &str = "möbius is running on an unsupported operating system.";
58    pub const SETTING_APPROVAL_POLICY_DESCRIPTION: &str =
59        "How approval-required actions receive mutation authority";
60    pub const SETTING_APPROVAL_POLICY_LABEL: &str = "Approval policy";
61}
62const APPROVAL_POLICIES: &[MiddlewareSettingChoice] = &[
63    MiddlewareSettingChoice {
64        disables: &[],
65        value: "ask",
66        label: text::APPROVAL_POLICY_ASK_LABEL,
67        description: text::APPROVAL_POLICY_ASK_DESCRIPTION,
68        symbol: Some("shield_check"),
69        tone: FrontendTone::Neutral,
70    },
71    MiddlewareSettingChoice {
72        disables: &[],
73        value: "allow",
74        label: text::APPROVAL_POLICY_ALLOW_LABEL,
75        description: text::APPROVAL_POLICY_ALLOW_DESCRIPTION,
76        symbol: Some("shield"),
77        tone: FrontendTone::Warning,
78    },
79    MiddlewareSettingChoice {
80        disables: &[],
81        value: "allow_network",
82        label: text::APPROVAL_POLICY_ALLOW_NETWORK_LABEL,
83        description: text::APPROVAL_POLICY_ALLOW_NETWORK_DESCRIPTION,
84        symbol: Some("shield_alert"),
85        tone: FrontendTone::Warning,
86    },
87    MiddlewareSettingChoice {
88        disables: &[],
89        value: "full_access",
90        label: text::APPROVAL_POLICY_FULL_ACCESS_LABEL,
91        description: text::APPROVAL_POLICY_FULL_ACCESS_DESCRIPTION,
92        symbol: Some("shield_off"),
93        tone: FrontendTone::Error,
94    },
95];
96const SETTINGS: &[MiddlewareSettingManifest] = &[MiddlewareSettingManifest::Select {
97    id: "approval_policy",
98    label: text::SETTING_APPROVAL_POLICY_LABEL,
99    description: text::SETTING_APPROVAL_POLICY_DESCRIPTION,
100    choices: MiddlewareSettingChoices::Static(APPROVAL_POLICIES),
101    unset_label: None,
102    default: Some(text::DEFAULTS_APPROVAL_POLICY),
103    max_bytes: 32,
104    composer: true,
105}];
106
107/// Configuration and presentation metadata for sandbox approval policy.
108pub const MANIFEST: MiddlewareManifest = MiddlewareManifest {
109    id: "sandbox",
110    label: text::MANIFEST_LABEL,
111    description: text::MANIFEST_DESCRIPTION,
112    required: true,
113    default_enabled: true,
114    required_model_capability: None,
115    settings: SETTINGS,
116};
117
118pub use approval::ApprovalPolicy;
119#[cfg(target_os = "macos")]
120#[doc(hidden)]
121pub use process_group::MACOS_COMMAND_WRAPPER;
122#[doc(hidden)]
123pub use process_group::ProcessGroupGuard;
124
125use approval::Approval;
126pub(crate) use background::BackgroundCommandPoll;
127#[cfg(test)]
128pub(crate) use background::BackgroundCommandStatus;
129use background::BackgroundCommands;
130
131/// Deny-by-default macOS Seatbelt prelude shared by first-party sandbox backends.
132///
133/// Backends must append their own filesystem allow rules before using it.
134#[cfg(target_os = "macos")]
135#[doc(hidden)]
136pub const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
137
138/// macOS platform services required by commands with approved network access.
139#[cfg(target_os = "macos")]
140#[doc(hidden)]
141pub const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
142
143/// Whether a sandbox backend permits network access for one command.
144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum NetworkAccess {
147    #[default]
148    /// Selects the denied case.
149    Denied,
150    /// Selects the allowed case.
151    Allowed,
152}
153
154/// Whether an operation uses workspace isolation or host-wide access.
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum SandboxMode {
158    #[default]
159    /// Selects the workspace write case.
160    WorkspaceWrite,
161    /// Selects the danger full access case.
162    DangerFullAccess,
163}
164
165/// Bounded output from a sandboxed command.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct CommandOutput {
168    /// The exit code.
169    pub exit_code: i32,
170    /// The stdout.
171    pub stdout: String,
172    /// The stdout truncated.
173    pub stdout_truncated: bool,
174    /// The stderr.
175    pub stderr: String,
176    /// The stderr truncated.
177    pub stderr_truncated: bool,
178}
179
180/// One byte stream emitted by a sandboxed command.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum CommandStream {
183    /// Selects the stdout case.
184    Stdout,
185    /// Selects the stderr case.
186    Stderr,
187}
188
189/// Whether command execution has a foreground deadline.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum CommandMode {
192    /// Selects the foreground case.
193    Foreground,
194    /// Selects the background case.
195    Background,
196}
197
198/// Optional observer for bounded command output consumers.
199#[derive(Clone, Default)]
200pub struct CommandOutputSink {
201    callback: Option<Arc<CommandOutputCallback>>,
202}
203
204type CommandOutputCallback = dyn Fn(CommandStream, &[u8]) + Send + Sync;
205
206/// Authorizes one command at its process-launch boundary.
207///
208/// Returning without invoking the callback denies the launch. Implementations must invoke it
209/// only while the authoritative authorization state is held.
210pub type CommandAuthorization =
211    Arc<dyn Fn(&mut dyn FnMut() -> Result<()>) -> Result<()> + Send + Sync>;
212
213impl CommandOutputSink {
214    pub(crate) fn new(callback: impl Fn(CommandStream, &[u8]) + Send + Sync + 'static) -> Self {
215        Self {
216            callback: Some(Arc::new(callback)),
217        }
218    }
219
220    /// Publishes one output chunk while the backend continues draining the stream.
221    pub fn write(&self, stream: CommandStream, bytes: &[u8]) {
222        if let Some(callback) = &self.callback {
223            callback(stream, bytes);
224        }
225    }
226}
227
228/// Implements one sandbox execution environment.
229///
230/// Returned futures are [`Send`] and may be dropped during execution. Backends
231/// own cancellation cleanup for the processes and resources they launch;
232/// dropping a future must not leave unmanaged commands running. [`Sandbox`]
233/// owns approval and background-command tracking, not arbitrary backend cleanup.
234pub trait SandboxBackend: Send + Sync {
235    /// Creates an independent temporary area and execution lifetime for a child agent.
236    /// # Errors
237    ///
238    /// Returns an error if validation or an operation required by this function fails.
239    fn isolated_execution(&self) -> Result<Arc<dyn SandboxBackend>> {
240        Err(Error::Sandbox(
241            "backend does not support isolated child execution".into(),
242        ))
243    }
244
245    /// Reports the private temporary path visible to command and file tools.
246    fn temporary_directory(&self) -> Option<PathBuf> {
247        None
248    }
249
250    /// Launches a persistent framed runtime inside this backend's execution boundary.
251    /// # Errors
252    ///
253    /// Returns an error if validation or an operation required by this function fails.
254    fn start_worker(
255        &self,
256        _command: &WorkerCommand,
257        _sandbox_mode: SandboxMode,
258        _network_access: NetworkAccess,
259    ) -> Result<WorkerProcess> {
260        Err(Error::Sandbox(
261            "persistent execution is unavailable on this backend".into(),
262        ))
263    }
264
265    /// Opens an authorized host-service channel for one worker evaluation.
266    /// Dropping the channel ends its authority; requests are never replayed.
267    fn worker_connection<'a>(
268        &'a self,
269        _session_id: &'a str,
270        _sandbox_mode: SandboxMode,
271    ) -> BoxFuture<'a, Result<tokio::io::DuplexStream>> {
272        Box::pin(async {
273            Err(Error::Sandbox(
274                "native desktop control is unavailable".into(),
275            ))
276        })
277    }
278
279    /// Reads a UTF-8 file under the requested isolation.
280    fn read<'a>(
281        &'a self,
282        path: &'a str,
283        sandbox_mode: SandboxMode,
284    ) -> BoxFuture<'a, Result<String>>;
285
286    /// Reads one binary file through a single bounded open handle under the requested isolation.
287    fn read_bytes<'a>(
288        &'a self,
289        path: &'a str,
290        max_bytes: usize,
291        sandbox_mode: SandboxMode,
292    ) -> BoxFuture<'a, Result<Vec<u8>>>;
293
294    /// Writes a UTF-8 file under the requested isolation.
295    fn write<'a>(
296        &'a self,
297        path: &'a str,
298        content: &'a str,
299        sandbox_mode: SandboxMode,
300    ) -> BoxFuture<'a, Result<()>>;
301
302    /// Runs a shell command and forwards drained output under the requested isolation.
303    fn execute<'a>(
304        &'a self,
305        command: &'a str,
306        sandbox_mode: SandboxMode,
307        network_access: NetworkAccess,
308        mode: CommandMode,
309        output: CommandOutputSink,
310    ) -> BoxFuture<'a, Result<CommandOutput>>;
311
312    /// Runs a shell command only when authorization launches it atomically.
313    ///
314    /// Backends without an atomic launch boundary fail closed.
315    fn execute_authorized<'a>(
316        &'a self,
317        _command: &'a str,
318        _sandbox_mode: SandboxMode,
319        _network_access: NetworkAccess,
320        _mode: CommandMode,
321        _output: CommandOutputSink,
322        _authorization: &'a CommandAuthorization,
323    ) -> BoxFuture<'a, Result<Option<CommandOutput>>> {
324        Box::pin(async { Ok(None) })
325    }
326}
327
328/// Approval-owning boundary around one execution backend.
329pub struct Sandbox {
330    backend: Arc<dyn SandboxBackend>,
331    approval: Approval,
332    background: BackgroundCommands,
333    workers: worker::Workers,
334    workspace_prompt: Option<String>,
335}
336
337impl Sandbox {
338    /// Creates a sandbox with its initial approval policy.
339    #[must_use]
340    pub fn new(backend: Arc<dyn SandboxBackend>, policy: ApprovalPolicy) -> Self {
341        Self {
342            backend,
343            approval: Approval::new(policy),
344            background: BackgroundCommands::default(),
345            workers: worker::Workers::default(),
346            workspace_prompt: None,
347        }
348    }
349
350    /// Creates a child execution boundary with independent temporary files and workers.
351    /// # Errors
352    ///
353    /// Returns an error if validation or an operation required by this function fails.
354    pub fn isolated_execution(&self) -> Result<Self> {
355        let mut scoped = Self::new(self.backend.isolated_execution()?, self.approval_policy());
356        scoped.workspace_prompt = self.workspace_prompt.clone();
357        Ok(scoped)
358    }
359
360    /// Adds the primary workspace and attached folder paths to the model prompt.
361    #[must_use]
362    pub fn attached_folders(mut self, primary: PathBuf, attached: Vec<PathBuf>) -> Self {
363        let mut prompt = format!("Primary workspace cwd: {primary:?}.");
364        if !attached.is_empty() {
365            prompt.push_str("\nAttached writable folders (use absolute paths):");
366            for path in attached {
367                prompt.push_str(&format!("\n- {path:?}"));
368            }
369        }
370        self.workspace_prompt = Some(prompt);
371        self
372    }
373
374    pub(crate) fn platform_prompt() -> &'static str {
375        if cfg!(target_os = "linux") {
376            text::PROMPT_LINUX
377        } else if cfg!(target_os = "macos") {
378            text::PROMPT_MACOS
379        } else {
380            text::PROMPT_OTHER
381        }
382    }
383
384    pub(crate) const fn approval_policy(&self) -> ApprovalPolicy {
385        self.approval.policy()
386    }
387
388    /// Reads a UTF-8 file.
389    pub fn read<'a>(
390        &'a self,
391        path: &'a str,
392        permissions: &'a ToolPermissions,
393    ) -> BoxFuture<'a, Result<String>> {
394        self.backend.read(path, permissions.sandbox_mode)
395    }
396
397    /// Reads one bounded binary file.
398    pub fn read_bytes<'a>(
399        &'a self,
400        path: &'a str,
401        max_bytes: usize,
402        permissions: &'a ToolPermissions,
403    ) -> BoxFuture<'a, Result<Vec<u8>>> {
404        if max_bytes == 0 || max_bytes > MAX_BINARY_FILE_BYTES {
405            return Box::pin(async {
406                Err(Error::Sandbox(format!(
407                    "binary file read size must be 1–{MAX_BINARY_FILE_BYTES} bytes"
408                )))
409            });
410        }
411        self.backend
412            .read_bytes(path, max_bytes, permissions.sandbox_mode)
413    }
414
415    /// Writes a UTF-8 file when this call has mutation authority.
416    pub fn write<'a>(
417        &'a self,
418        path: &'a str,
419        content: &'a str,
420        permissions: &'a ToolPermissions,
421    ) -> BoxFuture<'a, Result<()>> {
422        if !permissions.mutation {
423            return Box::pin(async {
424                Err(Error::Sandbox(
425                    "tool call is not authorized to mutate the workspace".into(),
426                ))
427            });
428        }
429        if content.len() > MAX_FILE_BYTES {
430            return Box::pin(async { Err(Error::Sandbox("file exceeds write limit".into())) });
431        }
432        self.backend.write(path, content, permissions.sandbox_mode)
433    }
434
435    /// Runs a command when this call has mutation authority.
436    pub fn execute<'a>(
437        &'a self,
438        command: &'a str,
439        permissions: &'a ToolPermissions,
440    ) -> BoxFuture<'a, Result<CommandOutput>> {
441        if !permissions.mutation {
442            return Box::pin(async {
443                Err(Error::Sandbox(
444                    "tool call is not authorized to execute commands".into(),
445                ))
446            });
447        }
448        self.backend.execute(
449            command,
450            permissions.sandbox_mode,
451            permissions.network_access,
452            CommandMode::Foreground,
453            CommandOutputSink::default(),
454        )
455    }
456
457    /// Evaluates one authorized request, preserving runtime state until explicit reset or loss.
458    /// # Errors
459    ///
460    /// Returns an error if validation or an operation required by this function fails.
461    pub async fn evaluate_worker(
462        &self,
463        command: &WorkerCommand,
464        permissions: &ToolPermissions,
465        request: &[u8],
466        timeout: std::time::Duration,
467        reset: bool,
468    ) -> Result<Vec<u8>> {
469        self.workers
470            .evaluate(
471                self.backend.as_ref(),
472                command,
473                permissions,
474                request,
475                timeout,
476                reset,
477            )
478            .await
479    }
480
481    pub(crate) async fn run_command(
482        &self,
483        command: String,
484        permissions: &ToolPermissions,
485        initial_wait: std::time::Duration,
486    ) -> Result<BackgroundCommandPoll> {
487        if !permissions.mutation {
488            return Err(Error::Sandbox(
489                "tool call is not authorized to execute commands".into(),
490            ));
491        }
492        self.background
493            .start(
494                &permissions.session_id,
495                Arc::clone(&self.backend),
496                command,
497                permissions.sandbox_mode,
498                permissions.network_access,
499            )?
500            .wait(&permissions.session_id, initial_wait)
501            .await
502    }
503
504    /// Reports whether one session still owns a background command result.
505    /// # Errors
506    ///
507    /// Returns an error if validation or an operation required by this function fails.
508    pub fn has_background_commands(&self, session_id: &str) -> Result<bool> {
509        self.background.has_owner(session_id)
510    }
511
512    pub(crate) async fn poll_background(
513        &self,
514        id: &str,
515        permissions: &ToolPermissions,
516    ) -> Result<BackgroundCommandPoll> {
517        self.background.poll(&permissions.session_id, id).await
518    }
519
520    pub(crate) async fn stop_background(
521        &self,
522        id: &str,
523        permissions: &ToolPermissions,
524    ) -> Result<BackgroundCommandPoll> {
525        self.background.stop(&permissions.session_id, id).await
526    }
527
528    pub(crate) fn frontend(&self) -> FrontendContribution {
529        self.approval.frontend()
530    }
531
532    pub(crate) fn render(&self, event: &EventMsg) -> Option<FrontendBlock> {
533        self.approval.render(event)
534    }
535
536    pub(crate) fn session_start(&self, session_id: &str) -> Result<Vec<FrontendEvent>> {
537        self.approval.session_start(session_id)
538    }
539
540    pub(crate) fn authorize(
541        &self,
542        session_id: &str,
543        calls: &[ToolCall],
544        mutation_call_ids: &[String],
545    ) -> Result<SandboxAuthorization> {
546        self.approval
547            .authorize(session_id, calls, mutation_call_ids)
548    }
549
550    pub(crate) fn resolve_approval(
551        &self,
552        session_id: &str,
553        calls: &[ToolCall],
554        approval_call_ids: &[String],
555        decision: &ReviewDecision,
556        permissions: SandboxPermissions,
557    ) -> Result<SandboxPermissions> {
558        self.approval
559            .resolve(session_id, calls, approval_call_ids, decision, permissions)
560    }
561
562    pub(crate) async fn session_end(&self, session_id: &str) -> Result<()> {
563        let approval = self.approval.session_end(session_id);
564        let background = self.background.shutdown(session_id).await;
565        self.workers.shutdown(session_id).await;
566        approval.and(background)
567    }
568}
569
570impl Middleware for Sandbox {
571    fn name(&self) -> &'static str {
572        MANIFEST.id
573    }
574
575    fn frontend(&self) -> FrontendContribution {
576        Sandbox::frontend(self)
577    }
578
579    fn prompt_section(&self, _runtime: &RuntimeContext) -> Result<Option<PromptSection>> {
580        let mut prompt = Sandbox::platform_prompt().to_owned();
581        if let Some(workspace) = &self.workspace_prompt {
582            prompt.push_str("\n\n");
583            prompt.push_str(workspace);
584        }
585        if let Some(temp) = self.backend.temporary_directory() {
586            prompt.push_str(&format!("\nPrivate temporary directory: {}. Shell and file tools share it across calls; child agents have separate temporary files. It is removed when this execution lifetime ends. Recorded images remain in history.", temp.display()));
587        }
588        Ok(Some(PromptSection::new(prompt)))
589    }
590
591    fn render(&self, event: &EventMsg, _session_id: &str) -> Option<FrontendBlock> {
592        Sandbox::render(self, event)
593    }
594
595    fn session_start<'a>(
596        &'a self,
597        context: &'a mut SessionStartContext<'_>,
598    ) -> BoxFuture<'a, Result<()>> {
599        Box::pin(async move {
600            if context.source() == SessionStartSource::Compact {
601                return Ok(());
602            }
603            for event in Sandbox::session_start(self, &context.runtime.session_id)? {
604                (context.runtime.frontend)(event)?;
605            }
606            Ok(())
607        })
608    }
609
610    fn session_end<'a>(&'a self, runtime: &'a RuntimeContext) -> BoxFuture<'a, Result<()>> {
611        Box::pin(async move { Sandbox::session_end(self, &runtime.session_id).await })
612    }
613}
614
615/// Batch authority issued by the sandbox approval policy.
616#[derive(Debug)]
617pub(crate) struct SandboxPermissions {
618    session_id: String,
619    sandbox_mode: SandboxMode,
620    network_access: NetworkAccess,
621    mutation_call_ids: BTreeSet<String>,
622}
623
624impl SandboxPermissions {
625    fn new(
626        session_id: impl Into<String>,
627        sandbox_mode: SandboxMode,
628        network_access: NetworkAccess,
629        mutation_call_ids: impl IntoIterator<Item = String>,
630    ) -> Self {
631        Self {
632            session_id: session_id.into(),
633            sandbox_mode,
634            network_access,
635            mutation_call_ids: mutation_call_ids.into_iter().collect(),
636        }
637    }
638
639    pub(crate) fn restore(
640        session_id: impl Into<String>,
641        sandbox_mode: SandboxMode,
642        network_access: NetworkAccess,
643        mutation_call_ids: impl IntoIterator<Item = String>,
644    ) -> Self {
645        Self::new(session_id, sandbox_mode, network_access, mutation_call_ids)
646    }
647
648    pub(crate) fn sandbox_mode(&self) -> SandboxMode {
649        self.sandbox_mode
650    }
651
652    pub(crate) fn network_access(&self) -> NetworkAccess {
653        self.network_access
654    }
655
656    pub(crate) fn mutation_call_ids(&self) -> Vec<String> {
657        self.mutation_call_ids.iter().cloned().collect()
658    }
659
660    pub(crate) fn for_call(&self, call_id: &str) -> ToolPermissions {
661        ToolPermissions {
662            session_id: self.session_id.clone(),
663            sandbox_mode: self.sandbox_mode,
664            network_access: self.network_access,
665            mutation: self.mutation_call_ids.contains(call_id),
666        }
667    }
668
669    fn allow_mutations(&mut self, call_ids: impl IntoIterator<Item = String>) {
670        self.mutation_call_ids.extend(call_ids);
671    }
672}
673
674/// Opaque authority attached to exactly one tool call.
675pub struct ToolPermissions {
676    session_id: String,
677    sandbox_mode: SandboxMode,
678    network_access: NetworkAccess,
679    mutation: bool,
680}
681
682impl ToolPermissions {
683    pub(crate) fn session_id(&self) -> &str {
684        &self.session_id
685    }
686
687    pub(crate) fn allows_mutation(&self) -> bool {
688        self.mutation
689    }
690}
691
692pub(crate) enum SandboxAuthorization {
693    Execute(SandboxPermissions),
694    Approval {
695        request: SandboxApprovalRequest,
696        permissions: SandboxPermissions,
697    },
698}
699
700pub(crate) struct SandboxApprovalRequest {
701    pub(crate) id: String,
702    pub(crate) reason: String,
703    pub(crate) call_ids: Vec<String>,
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use crate::backend::sandbox::local::LocalSandbox;
710    use crate::protocol::{FrontendSettingKind, FrontendSymbol};
711
712    #[test]
713    fn approval_policy_advertises_its_composer_presentation() {
714        let feature = MANIFEST.feature(&[]);
715        let setting = feature
716            .settings
717            .iter()
718            .find(|setting| setting.composer)
719            .expect("composer setting");
720        let FrontendSettingKind::Select { options, .. } = &setting.kind else {
721            panic!("composer setting must be a select");
722        };
723
724        assert_eq!(setting.id, "approval_policy");
725        assert_eq!(options[0].symbol, Some(FrontendSymbol::ShieldCheck));
726        assert_eq!(options[3].symbol, Some(FrontendSymbol::ShieldOff));
727        assert_eq!(options[3].tone, FrontendTone::Error);
728    }
729
730    #[test]
731    fn workspace_prompt_names_primary_and_attached_folders() {
732        let workspace = tempfile::tempdir().expect("workspace");
733        let sandbox = Sandbox::new(
734            Arc::new(LocalSandbox::new(workspace.path()).expect("backend")),
735            ApprovalPolicy::Ask,
736        )
737        .attached_folders(
738            PathBuf::from("/primary workspace"),
739            vec![PathBuf::from("/attached\nworkspace")],
740        );
741
742        assert_eq!(
743            sandbox.workspace_prompt.as_deref(),
744            Some(
745                "Primary workspace cwd: \"/primary workspace\".\nAttached writable folders (use absolute paths):\n- \"/attached\\nworkspace\""
746            )
747        );
748    }
749
750    #[tokio::test]
751    async fn mutation_fails_closed_without_per_call_authority() {
752        let workspace = tempfile::tempdir().expect("workspace");
753        let sandbox = Sandbox::new(
754            Arc::new(LocalSandbox::new(workspace.path()).expect("backend")),
755            ApprovalPolicy::Ask,
756        );
757        let permissions = SandboxPermissions::new(
758            "session",
759            SandboxMode::WorkspaceWrite,
760            NetworkAccess::Allowed,
761            Vec::new(),
762        );
763
764        assert!(
765            sandbox
766                .write("blocked.txt", "blocked", &permissions.for_call("call"))
767                .await
768                .is_err()
769        );
770        assert!(!workspace.path().join("blocked.txt").exists());
771    }
772}