1use 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
107pub 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#[cfg(target_os = "macos")]
135#[doc(hidden)]
136pub const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
137
138#[cfg(target_os = "macos")]
140#[doc(hidden)]
141pub const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
142
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum NetworkAccess {
147 #[default]
148 Denied,
150 Allowed,
152}
153
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum SandboxMode {
158 #[default]
159 WorkspaceWrite,
161 DangerFullAccess,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct CommandOutput {
168 pub exit_code: i32,
170 pub stdout: String,
172 pub stdout_truncated: bool,
174 pub stderr: String,
176 pub stderr_truncated: bool,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum CommandStream {
183 Stdout,
185 Stderr,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum CommandMode {
192 Foreground,
194 Background,
196}
197
198#[derive(Clone, Default)]
200pub struct CommandOutputSink {
201 callback: Option<Arc<CommandOutputCallback>>,
202}
203
204type CommandOutputCallback = dyn Fn(CommandStream, &[u8]) + Send + Sync;
205
206pub 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 pub fn write(&self, stream: CommandStream, bytes: &[u8]) {
222 if let Some(callback) = &self.callback {
223 callback(stream, bytes);
224 }
225 }
226}
227
228pub trait SandboxBackend: Send + Sync {
235 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 fn temporary_directory(&self) -> Option<PathBuf> {
247 None
248 }
249
250 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 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 fn read<'a>(
281 &'a self,
282 path: &'a str,
283 sandbox_mode: SandboxMode,
284 ) -> BoxFuture<'a, Result<String>>;
285
286 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 fn write<'a>(
296 &'a self,
297 path: &'a str,
298 content: &'a str,
299 sandbox_mode: SandboxMode,
300 ) -> BoxFuture<'a, Result<()>>;
301
302 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 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
328pub 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 #[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 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 #[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 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 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 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 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 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 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#[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
674pub 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}