Skip to main content

microclaw_engine/internal/tool_runtime/
sandbox.rs

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/// Container security profile controlling Linux capabilities and privilege escalation.
69///
70/// - `Hardened`: `--cap-drop ALL --security-opt no-new-privileges` (most restrictive; apt/chown/su will fail)
71/// - `Standard`: Docker default capabilities (apt/chown/su work normally)
72/// - `Privileged`: `--privileged` flag (full host-level access; use for debugging only)
73#[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    /// Credential-like environment variables are withheld from container
118    /// processes unless their exact name appears here. Dotenv files are parsed
119    /// on the host and are never mounted or passed wholesale to the container.
120    #[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/// An additional bind mount to expose inside the sandbox container.
202#[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        // Keep compatibility: treat empty allowlist as disabled.
712        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}