1use std::collections::HashMap;
2use std::path::{Component, Path, PathBuf};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::Duration;
6
7use anyhow::{bail, Context, Result};
8use async_trait::async_trait;
9
10use crate::internal::tool_runtime::command_runner::{build_command, shell_command};
11use serde::{Deserialize, Serialize};
12
13fn default_sandbox_mode() -> SandboxMode {
14 SandboxMode::Off
15}
16
17fn default_sandbox_backend() -> SandboxBackend {
18 SandboxBackend::Auto
19}
20
21fn default_sandbox_image() -> String {
22 "ubuntu:25.10".to_string()
23}
24
25fn default_sandbox_container_prefix() -> String {
26 "microclaw-sandbox".to_string()
27}
28
29fn default_sandbox_no_network() -> bool {
30 true
31}
32
33fn default_sandbox_require_runtime() -> bool {
34 true
35}
36
37fn default_sandbox_mount_allowlist_path() -> Option<String> {
38 None
39}
40
41fn default_sandbox_security_profile() -> SecurityProfile {
42 SecurityProfile::Hardened
43}
44
45fn default_sandbox_cap_add() -> Vec<String> {
46 Vec::new()
47}
48
49fn default_sandbox_credential_env_allowlist() -> Vec<String> {
50 Vec::new()
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SandboxMode {
56 Off,
57 All,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum SandboxBackend {
63 Auto,
64 Docker,
65 Podman,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum SecurityProfile {
76 Hardened,
77 Standard,
78 Privileged,
79}
80
81impl std::fmt::Display for SecurityProfile {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 SecurityProfile::Hardened => write!(f, "hardened"),
85 SecurityProfile::Standard => write!(f, "standard"),
86 SecurityProfile::Privileged => write!(f, "privileged"),
87 }
88 }
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct SandboxConfig {
93 #[serde(default = "default_sandbox_mode")]
94 pub mode: SandboxMode,
95 #[serde(default = "default_sandbox_backend")]
96 pub backend: SandboxBackend,
97 #[serde(default = "default_sandbox_image")]
98 pub image: String,
99 #[serde(default = "default_sandbox_container_prefix")]
100 pub container_prefix: String,
101 #[serde(default = "default_sandbox_no_network")]
102 pub no_network: bool,
103 #[serde(default = "default_sandbox_require_runtime")]
104 pub require_runtime: bool,
105 #[serde(default = "default_sandbox_mount_allowlist_path")]
106 pub mount_allowlist_path: Option<String>,
107 #[serde(default = "default_sandbox_security_profile")]
108 pub security_profile: SecurityProfile,
109 #[serde(default = "default_sandbox_cap_add")]
110 pub cap_add: Vec<String>,
111 #[serde(default)]
112 pub memory_limit: Option<String>,
113 #[serde(default)]
114 pub cpu_quota: Option<f64>,
115 #[serde(default)]
116 pub pids_limit: Option<u32>,
117 #[serde(default = "default_sandbox_credential_env_allowlist")]
121 pub credential_env_allowlist: Vec<String>,
122}
123
124impl Default for SandboxConfig {
125 fn default() -> Self {
126 Self {
127 mode: default_sandbox_mode(),
128 backend: default_sandbox_backend(),
129 image: default_sandbox_image(),
130 container_prefix: default_sandbox_container_prefix(),
131 no_network: default_sandbox_no_network(),
132 require_runtime: default_sandbox_require_runtime(),
133 mount_allowlist_path: default_sandbox_mount_allowlist_path(),
134 security_profile: default_sandbox_security_profile(),
135 cap_add: default_sandbox_cap_add(),
136 memory_limit: None,
137 cpu_quota: None,
138 pids_limit: None,
139 credential_env_allowlist: default_sandbox_credential_env_allowlist(),
140 }
141 }
142}
143
144#[derive(Debug, Clone)]
145pub struct SandboxExecOptions {
146 pub timeout: Duration,
147 pub working_dir: Option<PathBuf>,
148 #[allow(clippy::zero_sized_map_values)]
149 pub envs: HashMap<String, String>,
150 pub env_files: Vec<PathBuf>,
151}
152
153#[derive(Debug, Clone)]
154pub struct SandboxExecResult {
155 pub stdout: String,
156 pub stderr: String,
157 pub exit_code: i32,
158}
159
160#[async_trait]
161pub trait Sandbox: Send + Sync {
162 fn backend_name(&self) -> &'static str;
163 fn is_real(&self) -> bool {
164 true
165 }
166 async fn ensure_ready(&self, session_key: &str) -> Result<()>;
167 async fn exec(
168 &self,
169 session_key: &str,
170 command: &str,
171 opts: &SandboxExecOptions,
172 ) -> Result<SandboxExecResult>;
173}
174
175pub struct NoSandbox;
176
177#[async_trait]
178impl Sandbox for NoSandbox {
179 fn backend_name(&self) -> &'static str {
180 "none"
181 }
182
183 fn is_real(&self) -> bool {
184 false
185 }
186
187 async fn ensure_ready(&self, _session_key: &str) -> Result<()> {
188 Ok(())
189 }
190
191 async fn exec(
192 &self,
193 _session_key: &str,
194 command: &str,
195 opts: &SandboxExecOptions,
196 ) -> Result<SandboxExecResult> {
197 exec_host_command(command, opts).await
198 }
199}
200
201#[derive(Debug, Clone)]
203pub struct ExtraMount {
204 pub host_path: PathBuf,
205 pub read_only: bool,
206}
207
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209enum ContainerRuntime {
210 Docker,
211 Podman,
212}
213
214impl ContainerRuntime {
215 fn cli(self) -> &'static str {
216 match self {
217 ContainerRuntime::Docker => "docker",
218 ContainerRuntime::Podman => "podman",
219 }
220 }
221
222 fn label(self) -> &'static str {
223 match self {
224 ContainerRuntime::Docker => "docker",
225 ContainerRuntime::Podman => "podman",
226 }
227 }
228}
229
230fn pick_runtime(
231 backend: SandboxBackend,
232 docker_available: bool,
233 podman_available: bool,
234) -> Option<ContainerRuntime> {
235 match backend {
236 SandboxBackend::Auto => docker_available.then_some(ContainerRuntime::Docker),
237 SandboxBackend::Docker => docker_available.then_some(ContainerRuntime::Docker),
238 SandboxBackend::Podman => podman_available.then_some(ContainerRuntime::Podman),
239 }
240}
241
242fn resolve_runtime(backend: SandboxBackend) -> Option<ContainerRuntime> {
243 pick_runtime(
244 backend,
245 runtime_available("docker"),
246 runtime_available("podman"),
247 )
248}
249
250pub fn runtime_available_for_backend(backend: SandboxBackend) -> bool {
251 resolve_runtime(backend).is_some()
252}
253
254pub fn selected_runtime_cli(backend: SandboxBackend) -> Option<&'static str> {
255 resolve_runtime(backend).map(ContainerRuntime::cli)
256}
257
258pub struct DockerSandbox {
259 runtime: ContainerRuntime,
260 config: SandboxConfig,
261 mount_dir: PathBuf,
262 extra_mounts: Vec<ExtraMount>,
263}
264
265impl DockerSandbox {
266 fn new(
267 runtime: ContainerRuntime,
268 config: SandboxConfig,
269 mount_dir: PathBuf,
270 extra_mounts: Vec<ExtraMount>,
271 ) -> Self {
272 Self {
273 runtime,
274 config,
275 mount_dir,
276 extra_mounts,
277 }
278 }
279
280 fn container_name(&self, session_key: &str) -> String {
281 format!(
282 "{}-{}",
283 self.config.container_prefix,
284 sanitize_segment(session_key)
285 )
286 }
287
288 fn resource_args(&self) -> Vec<String> {
289 let mut args = Vec::new();
290 if let Some(mem) = &self.config.memory_limit {
291 args.extend(["--memory".to_string(), mem.clone()]);
292 }
293 if let Some(cpu) = self.config.cpu_quota {
294 args.extend(["--cpus".to_string(), cpu.to_string()]);
295 }
296 if let Some(pids) = self.config.pids_limit {
297 args.extend(["--pids-limit".to_string(), pids.to_string()]);
298 }
299 args
300 }
301
302 fn security_args(&self) -> Vec<String> {
303 let mut args = Vec::new();
304 match self.config.security_profile {
305 SecurityProfile::Hardened => {
306 args.extend([
307 "--cap-drop".to_string(),
308 "ALL".to_string(),
309 "--security-opt".to_string(),
310 "no-new-privileges".to_string(),
311 ]);
312 for cap in &self.config.cap_add {
313 args.extend(["--cap-add".to_string(), cap.clone()]);
314 }
315 }
316 SecurityProfile::Standard => {
317 for cap in &self.config.cap_add {
318 args.extend(["--cap-add".to_string(), cap.clone()]);
319 }
320 }
321 SecurityProfile::Privileged => {
322 args.push("--privileged".to_string());
323 }
324 }
325 args
326 }
327}
328
329#[async_trait]
330impl Sandbox for DockerSandbox {
331 fn backend_name(&self) -> &'static str {
332 self.runtime.label()
333 }
334
335 async fn ensure_ready(&self, session_key: &str) -> Result<()> {
336 let name = self.container_name(session_key);
337 let inspect = tokio::process::Command::new(self.runtime.cli())
338 .args(["inspect", "--format", "{{.State.Running}}", &name])
339 .output()
340 .await;
341 if let Ok(out) = inspect {
342 let stdout = String::from_utf8_lossy(&out.stdout);
343 if out.status.success() && stdout.trim() == "true" {
344 return Ok(());
345 }
346 }
347
348 let mut args = vec![
349 "run".to_string(),
350 "-d".to_string(),
351 "--name".to_string(),
352 name.clone(),
353 ];
354 args.extend(self.security_args());
355 if self.config.no_network {
356 args.push("--network=none".to_string());
357 }
358 args.extend(self.resource_args());
359 let mount = self.mount_dir.display().to_string();
360 args.extend(["-v".to_string(), format!("{mount}:{mount}:rw")]);
361 for em in &self.extra_mounts {
362 let p = em.host_path.display().to_string();
363 let mode = if em.read_only { "ro" } else { "rw" };
364 args.extend(["-v".to_string(), format!("{p}:{p}:{mode}")]);
365 }
366 args.push(self.config.image.clone());
367 args.extend(["sleep".to_string(), "infinity".to_string()]);
368
369 let out = tokio::process::Command::new(self.runtime.cli())
370 .args(&args)
371 .output()
372 .await
373 .with_context(|| format!("failed to run {}", self.runtime.cli()))?;
374 if !out.status.success() {
375 let stderr = String::from_utf8_lossy(&out.stderr);
376 bail!("{} run failed: {}", self.runtime.cli(), stderr.trim());
377 }
378 Ok(())
379 }
380
381 async fn exec(
382 &self,
383 session_key: &str,
384 command: &str,
385 opts: &SandboxExecOptions,
386 ) -> Result<SandboxExecResult> {
387 let name = self.container_name(session_key);
388 let mut args = vec!["exec".to_string()];
389
390 if let Some(dir) = &opts.working_dir {
391 args.extend(["-w".to_string(), dir.display().to_string()]);
392 }
393 for (k, v) in sandbox_environment(opts, &self.config.credential_env_allowlist) {
394 args.extend(["-e".to_string(), format!("{k}={v}")]);
395 }
396 args.push(name);
397 args.extend(["sh".to_string(), "-c".to_string(), command.to_string()]);
398 let child = tokio::process::Command::new(self.runtime.cli())
399 .args(&args)
400 .stdout(std::process::Stdio::piped())
401 .stderr(std::process::Stdio::piped())
402 .stdin(std::process::Stdio::null())
403 .spawn()
404 .with_context(|| format!("failed to spawn {} exec", self.runtime.cli()))?;
405 match tokio::time::timeout(opts.timeout, child.wait_with_output()).await {
406 Ok(Ok(output)) => Ok(SandboxExecResult {
407 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
408 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
409 exit_code: output.status.code().unwrap_or(-1),
410 }),
411 Ok(Err(e)) => bail!("{} exec failed: {e}", self.runtime.cli()),
412 Err(_) => bail!(
413 "{} exec timed out after {} seconds",
414 self.runtime.cli(),
415 opts.timeout.as_secs()
416 ),
417 }
418 }
419}
420
421pub struct SandboxRouter {
422 config: SandboxConfig,
423 backend: Arc<dyn Sandbox>,
424 warned_missing_runtime: AtomicBool,
425}
426
427impl SandboxRouter {
428 pub fn new(config: SandboxConfig, working_dir: &Path, extra_mounts: Vec<ExtraMount>) -> Self {
429 let mount_dir = resolve_mount_dir(working_dir, &config);
430 let backend: Arc<dyn Sandbox> = match resolve_runtime(config.backend) {
431 Some(runtime) => Arc::new(DockerSandbox::new(
432 runtime,
433 config.clone(),
434 mount_dir,
435 extra_mounts,
436 )),
437 None => Arc::new(NoSandbox),
438 };
439 Self {
440 config,
441 backend,
442 warned_missing_runtime: AtomicBool::new(false),
443 }
444 }
445
446 #[cfg(test)]
447 fn with_backend_for_tests(config: SandboxConfig, backend: Arc<dyn Sandbox>) -> Self {
448 Self {
449 config,
450 backend,
451 warned_missing_runtime: AtomicBool::new(false),
452 }
453 }
454
455 pub fn mode(&self) -> SandboxMode {
456 self.config.mode
457 }
458
459 pub fn backend_name(&self) -> &'static str {
460 self.backend.backend_name()
461 }
462
463 pub fn runtime_available(&self) -> bool {
464 self.backend.is_real()
465 }
466
467 pub async fn exec(
468 &self,
469 session_key: &str,
470 command: &str,
471 opts: &SandboxExecOptions,
472 ) -> Result<SandboxExecResult> {
473 if self.config.mode == SandboxMode::Off {
474 return exec_host_command(command, opts).await;
475 }
476 if !self.backend.is_real() {
477 if self.config.require_runtime {
478 bail!("sandbox is enabled but no container runtime is available");
479 }
480 if !self.warned_missing_runtime.swap(true, Ordering::Relaxed) {
481 tracing::warn!(
482 "sandbox enabled but no container runtime available, falling back to host"
483 );
484 }
485 return exec_host_command(command, opts).await;
486 }
487 self.backend.ensure_ready(session_key).await?;
488 self.backend.exec(session_key, command, opts).await
489 }
490}
491
492fn sandbox_environment(
493 opts: &SandboxExecOptions,
494 credential_allowlist: &[String],
495) -> HashMap<String, String> {
496 let mut env = HashMap::new();
497 for env_file in &opts.env_files {
498 if let Ok(content) = std::fs::read_to_string(env_file) {
499 for (key, value) in crate::internal::tool_runtime::env_file::parse_dotenv(&content) {
500 if sandbox_env_allowed(&key, credential_allowlist) {
501 env.insert(key, value);
502 }
503 }
504 }
505 }
506 for (key, value) in &opts.envs {
507 if sandbox_env_allowed(key, credential_allowlist) {
508 env.insert(key.clone(), value.clone());
509 }
510 }
511 env
512}
513
514fn sandbox_env_allowed(name: &str, credential_allowlist: &[String]) -> bool {
515 if credential_allowlist
516 .iter()
517 .any(|allowed| allowed.eq_ignore_ascii_case(name))
518 {
519 return true;
520 }
521 !looks_like_credential_env(name)
522}
523
524fn looks_like_credential_env(name: &str) -> bool {
525 let normalized = name.to_ascii_uppercase();
526 [
527 "API_KEY",
528 "ACCESS_KEY",
529 "PRIVATE_KEY",
530 "SECRET",
531 "TOKEN",
532 "PASSWORD",
533 "PASSWD",
534 "CREDENTIAL",
535 "AUTHORIZATION",
536 ]
537 .iter()
538 .any(|marker| normalized.contains(marker))
539}
540
541pub async fn exec_host_command(
542 command: &str,
543 opts: &SandboxExecOptions,
544) -> Result<SandboxExecResult> {
545 let spec = shell_command(command);
546 let mut cmd = build_command(&spec, opts.working_dir.as_deref());
547 for env_file in &opts.env_files {
548 if let Ok(content) = std::fs::read_to_string(env_file) {
549 for (k, v) in crate::internal::tool_runtime::env_file::parse_dotenv(&content) {
550 cmd.env(k, v);
551 }
552 }
553 }
554 for (k, v) in &opts.envs {
555 cmd.env(k, v);
556 }
557 cmd.stdout(std::process::Stdio::piped());
558 cmd.stderr(std::process::Stdio::piped());
559 cmd.stdin(std::process::Stdio::null());
560 let child = cmd.spawn().context("failed to start shell command")?;
561 match tokio::time::timeout(opts.timeout, child.wait_with_output()).await {
562 Ok(Ok(output)) => Ok(SandboxExecResult {
563 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
564 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
565 exit_code: output.status.code().unwrap_or(-1),
566 }),
567 Ok(Err(e)) => bail!("failed to run command: {e}"),
568 Err(_) => bail!("command timed out after {} seconds", opts.timeout.as_secs()),
569 }
570}
571
572fn runtime_available(cli: &str) -> bool {
573 std::process::Command::new(cli)
574 .arg("info")
575 .stdout(std::process::Stdio::null())
576 .stderr(std::process::Stdio::null())
577 .status()
578 .is_ok_and(|s| s.success())
579}
580
581fn resolve_mount_dir(working_dir: &Path, config: &SandboxConfig) -> PathBuf {
582 let _ = std::fs::create_dir_all(working_dir);
583 let canonical =
584 std::fs::canonicalize(working_dir).unwrap_or_else(|_| working_dir.to_path_buf());
585 if let Err(err) = validate_mount_dir(&canonical, config) {
586 tracing::warn!(
587 path = %canonical.display(),
588 error = %err,
589 "sandbox mount dir failed validation; falling back to raw working dir"
590 );
591 working_dir.to_path_buf()
592 } else {
593 canonical
594 }
595}
596
597fn sanitize_segment(input: &str) -> String {
598 let mut out = String::with_capacity(input.len());
599 for c in input.chars() {
600 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
601 out.push(c.to_ascii_lowercase());
602 } else {
603 out.push('-');
604 }
605 }
606 if out.is_empty() {
607 "default".into()
608 } else {
609 out
610 }
611}
612
613const MOUNT_BLOCKED_COMPONENTS: &[&str] = &[
614 ".ssh", ".gnupg", ".aws", ".azure", ".gcloud", ".kube", ".docker",
615];
616
617const MOUNT_BLOCKED_FILENAMES: &[&str] = &[
618 ".env",
619 ".env.local",
620 ".env.production",
621 ".env.development",
622 ".netrc",
623 ".npmrc",
624 "id_rsa",
625 "id_ed25519",
626 "credentials",
627 "private_key",
628];
629
630fn validate_mount_dir(path: &Path, config: &SandboxConfig) -> Result<()> {
631 if contains_symlink_component(path)? {
632 bail!("mount path contains symlink component");
633 }
634 if has_sensitive_mount_component(path) {
635 bail!("mount path contains sensitive component");
636 }
637 validate_mount_allowlist(path, config)
638}
639
640fn contains_symlink_component(path: &Path) -> Result<bool> {
641 let mut cur = PathBuf::new();
642 for comp in path.components() {
643 match comp {
644 Component::RootDir => {
645 cur.push(Path::new("/"));
646 }
647 Component::Prefix(prefix) => cur.push(prefix.as_os_str()),
648 Component::Normal(part) => {
649 cur.push(part);
650 let meta = std::fs::symlink_metadata(&cur)
651 .with_context(|| format!("failed to stat mount path '{}'", cur.display()))?;
652 if meta.file_type().is_symlink() {
653 return Ok(true);
654 }
655 }
656 Component::CurDir | Component::ParentDir => {}
657 }
658 }
659 Ok(false)
660}
661
662fn has_sensitive_mount_component(path: &Path) -> bool {
663 for component in path.components() {
664 let Component::Normal(segment) = component else {
665 continue;
666 };
667 let part = segment.to_string_lossy().to_string();
668 if MOUNT_BLOCKED_COMPONENTS.contains(&part.as_str()) {
669 return true;
670 }
671 if MOUNT_BLOCKED_FILENAMES.contains(&part.as_str()) {
672 return true;
673 }
674 }
675 false
676}
677
678fn default_allowlist_path() -> Option<PathBuf> {
679 let home = std::env::var_os("HOME")
680 .map(PathBuf::from)
681 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))?;
682 Some(home.join(".microclaw/sandbox-mount-allowlist.txt"))
683}
684
685fn validate_mount_allowlist(path: &Path, config: &SandboxConfig) -> Result<()> {
686 let allowlist_path = config
687 .mount_allowlist_path
688 .as_ref()
689 .map(PathBuf::from)
690 .or_else(|| std::env::var_os("MICROCLAW_SANDBOX_MOUNT_ALLOWLIST").map(PathBuf::from))
691 .or_else(default_allowlist_path);
692 let Some(file_path) = allowlist_path else {
693 return Ok(());
694 };
695 if !file_path.exists() {
696 return Ok(());
697 }
698 let content = std::fs::read_to_string(&file_path)
699 .with_context(|| format!("failed reading mount allowlist '{}'", file_path.display()))?;
700 let mut allowed_roots = Vec::new();
701 for raw in content.lines() {
702 let line = raw.trim();
703 if line.is_empty() || line.starts_with('#') {
704 continue;
705 }
706 let candidate = PathBuf::from(line);
707 let canonical = std::fs::canonicalize(&candidate).unwrap_or(candidate);
708 allowed_roots.push(canonical);
709 }
710 if allowed_roots.is_empty() {
711 return Ok(());
713 }
714 if allowed_roots.iter().any(|root| path.starts_with(root)) {
715 Ok(())
716 } else {
717 bail!(
718 "mount path '{}' is not allowed by '{}'",
719 path.display(),
720 file_path.display()
721 )
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use std::time::Duration;
729
730 #[test]
731 fn test_sanitize_segment() {
732 assert_eq!(sanitize_segment("Web:10001"), "web-10001");
733 }
734
735 #[test]
736 fn test_router_default_backend_name() {
737 let router = SandboxRouter::new(SandboxConfig::default(), Path::new("./tmp"), vec![]);
738 let name = router.backend_name();
739 assert!(name == "docker" || name == "podman" || name == "none");
740 }
741
742 #[tokio::test]
743 async fn test_router_falls_back_to_host_when_runtime_missing_and_not_required() {
744 let cfg = SandboxConfig {
745 mode: SandboxMode::All,
746 backend: SandboxBackend::Auto,
747 require_runtime: false,
748 ..SandboxConfig::default()
749 };
750 let router = SandboxRouter::with_backend_for_tests(cfg, Arc::new(NoSandbox));
751 let opts = SandboxExecOptions {
752 timeout: Duration::from_secs(2),
753 working_dir: None,
754 envs: HashMap::new(),
755 env_files: Vec::new(),
756 };
757 #[cfg(target_os = "windows")]
758 let command = "Write-Output -NoEnumerate microclaw-smoke";
759 #[cfg(not(target_os = "windows"))]
760 let command = "printf microclaw-smoke";
761
762 let out = router.exec("chat-1", command, &opts).await;
763 let out = out.expect("expected host fallback execution");
764 assert_eq!(out.exit_code, 0);
765 assert_eq!(out.stdout.trim_end(), "microclaw-smoke");
766 }
767
768 #[tokio::test]
769 async fn test_router_fails_closed_when_runtime_required_and_missing() {
770 let cfg = SandboxConfig {
771 mode: SandboxMode::All,
772 backend: SandboxBackend::Auto,
773 require_runtime: true,
774 ..SandboxConfig::default()
775 };
776 let router = SandboxRouter::with_backend_for_tests(cfg, Arc::new(NoSandbox));
777 let opts = SandboxExecOptions {
778 timeout: Duration::from_secs(2),
779 working_dir: None,
780 envs: HashMap::new(),
781 env_files: Vec::new(),
782 };
783 let err = router.exec("chat-1", "echo hi", &opts).await.unwrap_err();
784 assert!(err
785 .to_string()
786 .contains("sandbox is enabled but no container runtime is available"));
787 }
788
789 #[test]
790 fn test_pick_runtime_matrix() {
791 assert_eq!(
792 pick_runtime(SandboxBackend::Auto, true, true),
793 Some(ContainerRuntime::Docker)
794 );
795 assert_eq!(pick_runtime(SandboxBackend::Auto, false, true), None);
796 assert_eq!(pick_runtime(SandboxBackend::Auto, false, false), None);
797 assert_eq!(
798 pick_runtime(SandboxBackend::Docker, true, false),
799 Some(ContainerRuntime::Docker)
800 );
801 assert_eq!(pick_runtime(SandboxBackend::Docker, false, true), None);
802 assert_eq!(
803 pick_runtime(SandboxBackend::Podman, false, true),
804 Some(ContainerRuntime::Podman)
805 );
806 assert_eq!(pick_runtime(SandboxBackend::Podman, true, false), None);
807 }
808
809 #[test]
810 fn sandbox_environment_withholds_credentials_by_default() {
811 let dir = tempfile::tempdir().unwrap();
812 let env_file = dir.path().join("plugin.env");
813 std::fs::write(
814 &env_file,
815 "PUBLIC_MODE=fast\nSERVICE_API_KEY=do-not-forward\n",
816 )
817 .unwrap();
818 let opts = SandboxExecOptions {
819 timeout: Duration::from_secs(1),
820 working_dir: None,
821 envs: HashMap::from([
822 ("LANG".to_string(), "C.UTF-8".to_string()),
823 ("AUTH_TOKEN".to_string(), "secret".to_string()),
824 ]),
825 env_files: vec![env_file],
826 };
827
828 let env = sandbox_environment(&opts, &[]);
829
830 assert_eq!(env.get("PUBLIC_MODE").map(String::as_str), Some("fast"));
831 assert_eq!(env.get("LANG").map(String::as_str), Some("C.UTF-8"));
832 assert!(!env.contains_key("SERVICE_API_KEY"));
833 assert!(!env.contains_key("AUTH_TOKEN"));
834 }
835
836 #[test]
837 fn sandbox_environment_forwards_explicitly_allowed_credential() {
838 let opts = SandboxExecOptions {
839 timeout: Duration::from_secs(1),
840 working_dir: None,
841 envs: HashMap::from([("PLUGIN_TOKEN".to_string(), "secret".to_string())]),
842 env_files: Vec::new(),
843 };
844
845 let env = sandbox_environment(&opts, &["plugin_token".to_string()]);
846
847 assert_eq!(env.get("PLUGIN_TOKEN").map(String::as_str), Some("secret"));
848 }
849}