Skip to main content

zeroclaw_runtime/security/
detect.rs

1//! Auto-detection of available security features
2
3use crate::security::traits::Sandbox;
4use std::path::Path;
5use std::sync::Arc;
6use zeroclaw_config::schema::{SandboxBackend, SandboxConfig};
7
8/// Create a sandbox based on auto-detection or explicit config.
9///
10/// Takes a [`SandboxConfig`] (synthesized from the active risk profile via
11/// `RiskProfileConfig::sandbox_config()`). `runtime_kind` is the
12/// `runtime.kind` string from the top-level config. When the caller has set
13/// `runtime.kind = "native"`, Docker must never be selected as the sandbox
14/// backend during auto-detection — the user explicitly opted out of container
15/// wrapping.
16pub fn create_sandbox(
17    sandbox: &SandboxConfig,
18    runtime_kind: &str,
19    workspace_dir: Option<&Path>,
20) -> Arc<dyn Sandbox> {
21    let backend = &sandbox.backend;
22
23    // If explicitly disabled, return noop
24    if matches!(backend, SandboxBackend::None) || sandbox.enabled == Some(false) {
25        return Arc::new(super::traits::NoopSandbox);
26    }
27
28    // If specific backend requested, try that
29    match backend {
30        SandboxBackend::Landlock => {
31            #[cfg(feature = "sandbox-landlock")]
32            {
33                #[cfg(target_os = "linux")]
34                {
35                    if let Ok(sandbox) = super::landlock::LandlockSandbox::with_workspace(
36                        workspace_dir.map(Path::to_path_buf),
37                    ) {
38                        return Arc::new(sandbox);
39                    }
40                }
41            }
42            ::zeroclaw_log::record!(
43                WARN,
44                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
45                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
46                "Landlock requested but not available, falling back to application-layer"
47            );
48            Arc::new(super::traits::NoopSandbox)
49        }
50        SandboxBackend::Firejail => {
51            #[cfg(target_os = "linux")]
52            {
53                if let Ok(sandbox) = super::firejail::FirejailSandbox::new() {
54                    return Arc::new(sandbox);
55                }
56            }
57            ::zeroclaw_log::record!(
58                WARN,
59                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
60                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
61                "Firejail requested but not available, falling back to application-layer"
62            );
63            Arc::new(super::traits::NoopSandbox)
64        }
65        SandboxBackend::Bubblewrap => {
66            #[cfg(feature = "sandbox-bubblewrap")]
67            {
68                #[cfg(any(target_os = "linux", target_os = "macos"))]
69                {
70                    if let Ok(sandbox) = super::bubblewrap::BubblewrapSandbox::new() {
71                        return Arc::new(sandbox);
72                    }
73                }
74            }
75            ::zeroclaw_log::record!(
76                WARN,
77                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
78                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
79                "Bubblewrap requested but not available, falling back to application-layer"
80            );
81            Arc::new(super::traits::NoopSandbox)
82        }
83        SandboxBackend::Docker => {
84            let result = if let Some(ws) = workspace_dir {
85                super::docker::DockerSandbox::with_workspace(
86                    super::docker::DockerSandbox::default_image(),
87                    ws.to_path_buf(),
88                )
89            } else {
90                super::docker::DockerSandbox::new()
91            };
92            if let Ok(sandbox) = result {
93                return Arc::new(sandbox);
94            }
95            ::zeroclaw_log::record!(
96                WARN,
97                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
98                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
99                "Docker requested but not available, falling back to application-layer"
100            );
101            Arc::new(super::traits::NoopSandbox)
102        }
103        SandboxBackend::SandboxExec => {
104            #[cfg(target_os = "macos")]
105            {
106                if let Ok(sandbox) = super::seatbelt::SeatbeltSandbox::with_workspace(workspace_dir)
107                {
108                    return Arc::new(sandbox);
109                }
110            }
111            ::zeroclaw_log::record!(
112                WARN,
113                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
114                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
115                "sandbox-exec requested but not available, falling back to application-layer"
116            );
117            Arc::new(super::traits::NoopSandbox)
118        }
119        SandboxBackend::Auto | SandboxBackend::None => {
120            // Auto-detect best available, skipping Docker when native runtime is in use
121            detect_best_sandbox(runtime_kind, workspace_dir)
122        }
123    }
124}
125
126/// Auto-detect the best available sandbox.
127///
128/// When `runtime_kind` is `"native"` the caller has explicitly opted out of
129/// container wrapping, so Docker is excluded from consideration even if it is
130/// installed on the host.
131fn detect_best_sandbox(runtime_kind: &str, workspace_dir: Option<&Path>) -> Arc<dyn Sandbox> {
132    let skip_docker = runtime_kind == "native";
133
134    #[cfg(target_os = "linux")]
135    {
136        // Try Landlock first (native, no dependencies)
137        #[cfg(feature = "sandbox-landlock")]
138        {
139            if let Ok(sandbox) = super::landlock::LandlockSandbox::with_workspace(
140                workspace_dir.map(Path::to_path_buf),
141            ) {
142                ::zeroclaw_log::record!(
143                    INFO,
144                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
145                    "Landlock sandbox enabled (Linux kernel 5.13+)"
146                );
147                return Arc::new(sandbox);
148            }
149        }
150
151        // Try Firejail second (user-space tool)
152        if let Ok(sandbox) = super::firejail::FirejailSandbox::probe() {
153            ::zeroclaw_log::record!(
154                INFO,
155                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
156                "Firejail sandbox enabled"
157            );
158            return Arc::new(sandbox);
159        }
160    }
161
162    #[cfg(target_os = "macos")]
163    {
164        // Try Bubblewrap on macOS
165        #[cfg(feature = "sandbox-bubblewrap")]
166        {
167            if let Ok(sandbox) = super::bubblewrap::BubblewrapSandbox::probe() {
168                ::zeroclaw_log::record!(
169                    INFO,
170                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
171                    "Bubblewrap sandbox enabled"
172                );
173                return Arc::new(sandbox);
174            }
175        }
176
177        // Try sandbox-exec (Seatbelt) — built into macOS
178        if let Ok(sandbox) = super::seatbelt::SeatbeltSandbox::with_workspace(workspace_dir) {
179            ::zeroclaw_log::record!(
180                INFO,
181                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
182                "macOS sandbox-exec (Seatbelt) enabled"
183            );
184            return Arc::new(sandbox);
185        }
186    }
187
188    // Docker is heavy but works everywhere if docker is installed.
189    // Skip it when runtime.kind = "native" — the user explicitly opted out of
190    // container wrapping, and forcing Docker would break Python skills (Alpine
191    // has no python3) and workspace access on resource-constrained hosts.
192    if !skip_docker {
193        let docker_result = if let Some(ws) = workspace_dir {
194            super::docker::DockerSandbox::with_workspace(
195                super::docker::DockerSandbox::default_image(),
196                ws.to_path_buf(),
197            )
198        } else {
199            super::docker::DockerSandbox::probe()
200        };
201        if let Ok(sandbox) = docker_result {
202            ::zeroclaw_log::record!(
203                INFO,
204                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
205                "Docker sandbox enabled"
206            );
207            return Arc::new(sandbox);
208        }
209    } else {
210        ::zeroclaw_log::record!(
211            DEBUG,
212            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
213            "Docker sandbox skipped: runtime.kind = \"native\" overrides auto-detection"
214        );
215    }
216
217    // Fallback: application-layer security only
218    ::zeroclaw_log::record!(
219        INFO,
220        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
221        "No sandbox backend available, using application-layer security"
222    );
223    Arc::new(super::traits::NoopSandbox)
224}
225
226/// Returns true if the Linux kernel has the memory cgroup controller enabled.
227///
228/// Probes cgroup v2 (`/sys/fs/cgroup/memory.max`), then cgroup v1
229/// (`/sys/fs/cgroup/memory/memory.limit_in_bytes`), then `/proc/cgroups`.
230/// Any read error is treated as "absent" (conservative/safe direction).
231#[cfg(target_os = "linux")]
232pub fn linux_memcg_available() -> bool {
233    use std::path::Path;
234
235    if Path::new("/sys/fs/cgroup/memory.max").exists() {
236        return true;
237    }
238    if Path::new("/sys/fs/cgroup/memory/memory.limit_in_bytes").exists() {
239        return true;
240    }
241    if let Ok(content) = std::fs::read_to_string("/proc/cgroups") {
242        for line in content.lines() {
243            if line.starts_with('#') {
244                continue;
245            }
246            let mut cols = line.split_whitespace();
247            let name = cols.next().unwrap_or("");
248            let _hierarchy = cols.next();
249            let _num_cgroups = cols.next();
250            let enabled = cols.next().unwrap_or("0");
251            if name == "memory" && enabled == "1" {
252                return true;
253            }
254        }
255    }
256    false
257}
258
259/// Non-Linux stub — always returns false.
260/// Exists so the symbol compiles on all platforms (used in cross-platform tests).
261#[cfg(not(target_os = "linux"))]
262pub fn linux_memcg_available() -> bool {
263    false
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn detect_best_sandbox_returns_something() {
272        let sandbox = detect_best_sandbox("", None);
273        // Should always return at least NoopSandbox
274        assert!(sandbox.is_available());
275    }
276
277    #[test]
278    fn explicit_none_returns_noop() {
279        let sandbox_cfg = SandboxConfig {
280            enabled: Some(false),
281            backend: SandboxBackend::None,
282            firejail_args: Vec::new(),
283        };
284        let sandbox = create_sandbox(&sandbox_cfg, "", None);
285        assert_eq!(sandbox.name(), "none");
286    }
287
288    #[test]
289    fn auto_mode_detects_something() {
290        let sandbox_cfg = SandboxConfig {
291            enabled: None, // Auto-detect
292            backend: SandboxBackend::Auto,
293            firejail_args: Vec::new(),
294        };
295        let sandbox = create_sandbox(&sandbox_cfg, "", None);
296        // Should return some sandbox (at least NoopSandbox)
297        assert!(sandbox.is_available());
298    }
299
300    #[test]
301    fn native_runtime_with_auto_sandbox_never_selects_docker() {
302        // When runtime.kind = "native", Docker must be skipped in auto-detection
303        // even when Docker is installed on the host. The sandbox must be
304        // NoopSandbox or something OS-native (Landlock, Firejail, Seatbelt).
305        let sandbox = detect_best_sandbox("native", None);
306        assert_ne!(sandbox.name(), "docker");
307    }
308
309    #[test]
310    fn explicit_docker_backend_is_not_blocked_by_native_runtime() {
311        // Even with runtime.kind = "native", explicit `backend = "docker"` in config
312        // is respected. Only the auto-detect path is gated by runtime_kind.
313        let sandbox_cfg = SandboxConfig {
314            enabled: None,
315            backend: SandboxBackend::Docker,
316            firejail_args: Vec::new(),
317        };
318        let sandbox = create_sandbox(&sandbox_cfg, "native", None);
319        // If Docker is available, it will be selected; if not, NoopSandbox fallback.
320        assert!(sandbox.is_available());
321    }
322
323    #[test]
324    fn linux_memcg_available_returns_bool() {
325        let _result: bool = linux_memcg_available();
326    }
327
328    #[cfg(target_os = "linux")]
329    #[test]
330    fn linux_memcg_cgroup_v2_path_probe_does_not_panic() {
331        let _ = std::path::Path::new("/sys/fs/cgroup/memory.max").exists();
332    }
333
334    #[cfg(target_os = "linux")]
335    #[test]
336    fn linux_memcg_proc_cgroups_parses_without_panic() {
337        if let Ok(content) = std::fs::read_to_string("/proc/cgroups") {
338            let _found = content.lines().filter(|l| !l.starts_with('#')).any(|l| {
339                let mut f = l.split_whitespace();
340                let name = f.next().unwrap_or("");
341                let _hier = f.next();
342                let _num = f.next();
343                let enabled = f.next().unwrap_or("0");
344                name == "memory" && enabled == "1"
345            });
346        }
347    }
348}