leviath_sys/sandbox.rs
1//! Sandbox command construction for isolated tool execution.
2//!
3//! Builds the argv needed to run a shell command inside a container or a fresh
4//! set of Linux namespaces (`unshare(1)`), plus container-engine detection. This
5//! module is **pure argv assembly + PATH probing** - it never spawns a process
6//! itself. The caller (the daemon's `SandboxManager`) owns the actual
7//! `tokio::process::Command` spawn and container lifecycle, so everything here is
8//! deterministic and unit-testable without any runtime installed.
9//!
10//! The container engine is just a **binary name** (`"docker"`, `"podman"`,
11//! `"nerdctl"`, `"finch"`, …): Leviath isn't prescriptive about which one you
12//! run, only that it speaks the common `run`/`exec`/`rm` verbs. Auto-detection
13//! ([`detect_container_engine`]) prefers Docker then Podman, but a blueprint can
14//! name any binary.
15//!
16//! Keeping the sole namespace-vs-container / Linux-vs-not knowledge here (behind
17//! [`namespace_supported`] and a `cfg!`) means callers stay platform-agnostic,
18//! consistent with the rest of this crate.
19
20/// The container engines auto-detection probes for, in preference order. A
21/// blueprint may name any other Docker-CLI-compatible binary explicitly.
22pub const KNOWN_ENGINES: &[&str] = &["docker", "podman"];
23
24/// Detect an available container engine on `PATH`, returning its binary name.
25/// Prefers Docker, then Podman (see [`KNOWN_ENGINES`]).
26pub fn detect_container_engine() -> Option<String> {
27 detect_container_engine_with(&binary_on_path)
28}
29
30/// Testable core of [`detect_container_engine`]: `exists` reports whether a
31/// binary name is available. First match in [`KNOWN_ENGINES`] wins.
32pub fn detect_container_engine_with(exists: &dyn Fn(&str) -> bool) -> Option<String> {
33 KNOWN_ENGINES
34 .iter()
35 .find(|bin| exists(bin))
36 .map(|bin| bin.to_string())
37}
38
39/// Whether `bin` resolves to a regular file on any `PATH` entry.
40pub fn binary_on_path(bin: &str) -> bool {
41 binary_on_path_in(std::env::var_os("PATH"), bin)
42}
43
44/// Testable core of [`binary_on_path`]: `path` is the raw `PATH` value (`None`
45/// when the variable is unset, which yields `false`).
46fn binary_on_path_in(path: Option<std::ffi::OsString>, bin: &str) -> bool {
47 match path {
48 Some(paths) => std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()),
49 None => false,
50 }
51}
52
53/// Whether Linux-namespace sandboxing (`unshare`) is possible on this host.
54/// Only Linux ships the namespaces + `unshare(1)` this relies on.
55pub fn namespace_supported() -> bool {
56 cfg!(target_os = "linux")
57}
58
59/// Parameters for creating a long-lived, exec-able container.
60#[derive(Debug, Clone)]
61pub struct ContainerRunSpec<'a> {
62 /// The engine binary to invoke (e.g. `"docker"`, `"podman"`, `"nerdctl"`).
63 pub engine: &'a str,
64 /// Container image, e.g. `"ubuntu:24.04"`.
65 pub image: &'a str,
66 /// Absolute host workdir; bind-mounted at the same path and set as the
67 /// container's working directory.
68 pub workdir: &'a str,
69 /// Whether the container has network access (`false` → `--network none`).
70 pub network: bool,
71 /// Extra host paths to bind-mount (each mounted at its own path).
72 pub mounts: &'a [String],
73 /// Container name, used later for `exec` and `rm`.
74 pub name: &'a str,
75}
76
77/// Host paths that must never be bind-mounted into an agent's container.
78///
79/// `mounts` comes from the blueprint - a file the user downloaded - and was
80/// interpolated straight into `-v {m}:{m}`. `mounts = ["/var/run/docker.sock"]`
81/// is a one-line container escape (the container can then create a *privileged*
82/// container on the host), and `mounts = ["/"]` makes the isolation decorative.
83///
84/// Matched as path prefixes, so `/var/run/docker.sock/..`-style near-misses and
85/// anything under a forbidden root are refused together.
86const FORBIDDEN_MOUNTS: &[&str] = &[
87 "/",
88 "/proc",
89 "/sys",
90 "/dev",
91 "/etc",
92 "/boot",
93 "/var/run",
94 "/run",
95 // The container runtime's own socket, under either common path.
96 "/var/run/docker.sock",
97 "/run/docker.sock",
98 "/var/run/podman",
99];
100
101/// Whether `path` is safe to bind-mount into an agent container.
102///
103/// Refuses a fixed set of host-infrastructure roots and anything beneath them
104/// (`/`, `/proc`, `/sys`, `/dev`, `/etc`, `/boot`, `/run`, `/var/run`, and the
105/// container engines' own sockets), plus any
106/// relative path (which the engine would resolve against its own cwd, not ours)
107/// and anything containing `..`.
108pub fn mount_allowed(path: &str) -> bool {
109 // POSIX semantics spelled out rather than `std::path::Path`, because these
110 // are paths *inside a Linux container* - the host's rules do not apply to
111 // them. On Windows `Path::new("/data").is_absolute()` is false (an absolute
112 // path there needs a drive letter), so routing this through `Path` refused
113 // every legitimate container mount on Windows while behaving correctly on
114 // Unix. Fail-closed, so not a hole - but containers were unusable, and no
115 // test caught it because the host and the container agreed on every
116 // platform the tests ran on.
117 let absolute = path.starts_with('/');
118 let traverses = path.split('/').any(|segment| segment == "..");
119 if !absolute || traverses {
120 return false;
121 }
122 // Normalize a trailing slash so "/etc/" and "/etc" compare alike.
123 let normalized = path.trim_end_matches('/');
124 let normalized = match normalized.is_empty() {
125 true => "/",
126 false => normalized,
127 };
128 !FORBIDDEN_MOUNTS.iter().any(|forbidden| {
129 normalized == *forbidden
130 || (*forbidden != "/" && normalized.starts_with(&format!("{forbidden}/")))
131 || *forbidden == "/" && normalized == "/"
132 })
133}
134
135/// argv to start a detached, auto-removed container that idles (`sleep
136/// infinity`) so shell calls can `exec` into it repeatedly (warm container).
137///
138/// Hardened beyond plain `run`: the container drops every capability, cannot
139/// regain privileges via setuid binaries, and is bounded in processes and
140/// memory. Without these it ran as **root inside** with the default capability
141/// set - so "sandboxed" bought isolation of the filesystem view and nothing
142/// else, and anything written to the bind-mounted workdir came back root-owned
143/// on the host.
144///
145/// `mounts` entries are filtered through [`mount_allowed`]; a refused entry is
146/// dropped rather than silently honored.
147pub fn container_run_argv(spec: &ContainerRunSpec) -> Vec<String> {
148 let mut v = vec![
149 spec.engine.to_string(),
150 "run".to_string(),
151 "-d".to_string(),
152 "--rm".to_string(),
153 "--name".to_string(),
154 spec.name.to_string(),
155 // No capabilities: an agent's shell needs none of them, and `CAP_SYS_ADMIN`
156 // or `CAP_DAC_OVERRIDE` in particular turn a container into a foothold.
157 "--cap-drop".to_string(),
158 "ALL".to_string(),
159 // A setuid binary inside the image cannot escalate back to root.
160 "--security-opt".to_string(),
161 "no-new-privileges".to_string(),
162 // Bound fork bombs and runaway memory so one agent cannot take the host
163 // down with it.
164 "--pids-limit".to_string(),
165 "512".to_string(),
166 "--memory".to_string(),
167 "2g".to_string(),
168 ];
169 if !spec.network {
170 v.push("--network".to_string());
171 v.push("none".to_string());
172 }
173 // The agent's workdir is always mounted at the same path so file tools
174 // (which run on the host) and shell tools (which run in the container) see
175 // identical paths.
176 v.push("-v".to_string());
177 v.push(format!("{0}:{0}", spec.workdir));
178 for m in spec.mounts.iter().filter(|m| mount_allowed(m)) {
179 v.push("-v".to_string());
180 v.push(format!("{m}:{m}"));
181 }
182 v.push("-w".to_string());
183 v.push(spec.workdir.to_string());
184 v.push(spec.image.to_string());
185 v.push("sleep".to_string());
186 v.push("infinity".to_string());
187 v
188}
189
190/// argv to run one shell command inside a running container.
191///
192/// `shell`/`flag` are the shell *inside the container* (typically `sh`/`-c`,
193/// which every image ships) - NOT the host's shell, whose absolute path may not
194/// exist in the image.
195pub fn container_exec_argv(
196 engine: &str,
197 name: &str,
198 workdir: &str,
199 shell: &str,
200 flag: &str,
201 command: &str,
202) -> Vec<String> {
203 vec![
204 engine.to_string(),
205 "exec".to_string(),
206 "-w".to_string(),
207 workdir.to_string(),
208 name.to_string(),
209 shell.to_string(),
210 flag.to_string(),
211 command.to_string(),
212 ]
213}
214
215/// argv to force-remove a container (best-effort teardown).
216pub fn container_rm_argv(engine: &str, name: &str) -> Vec<String> {
217 vec![
218 engine.to_string(),
219 "rm".to_string(),
220 "-f".to_string(),
221 name.to_string(),
222 ]
223}
224
225/// argv to run one shell command under fresh Linux namespaces via `unshare(1)`.
226///
227/// Uses an unprivileged user namespace (`--user --map-root-user`) so it works
228/// without root, plus fresh mount + PID namespaces (`--mount --pid --fork
229/// --mount-proc`). `network = false` adds `--net`, giving an empty network
230/// namespace with no connectivity. The caller sets the child's working
231/// directory, so no `cd` is embedded here.
232///
233/// # This is not a filesystem sandbox
234///
235/// There is no `pivot_root`, no `chroot`, and no seccomp filter: the command
236/// **shares the host root filesystem** and can read `~/.ssh` or write
237/// `~/.leviath/providers/*.rhai` exactly as an unsandboxed command could. What
238/// `namespace` actually buys is PID isolation and, with `network = false`, no
239/// connectivity - genuinely useful, and not what most people mean by "sandbox".
240///
241/// Choose `kind = "container"` when the goal is to contain what an agent can
242/// *reach*. This mode is for bounding what it can *see running* and talk to.
243/// Said plainly here because a caller who assumes otherwise gets a weaker
244/// guarantee than they think, and the name invites that assumption.
245pub fn namespace_argv(shell: &str, flag: &str, command: &str, network: bool) -> Vec<String> {
246 let mut v = vec![
247 "unshare".to_string(),
248 "--user".to_string(),
249 "--map-root-user".to_string(),
250 "--mount".to_string(),
251 "--pid".to_string(),
252 "--fork".to_string(),
253 "--mount-proc".to_string(),
254 ];
255 if !network {
256 v.push("--net".to_string());
257 }
258 v.push(shell.to_string());
259 v.push(flag.to_string());
260 v.push(command.to_string());
261 v
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn detect_prefers_docker_then_podman_then_none() {
270 assert_eq!(
271 // `.contains` (not `||`) so no short-circuited operand region is left
272 // uncovered when "docker" matches first.
273 detect_container_engine_with(&|b| ["docker", "podman"].contains(&b)).as_deref(),
274 Some("docker")
275 );
276 assert_eq!(
277 detect_container_engine_with(&|b| b == "podman").as_deref(),
278 Some("podman")
279 );
280 assert_eq!(detect_container_engine_with(&|_| false), None);
281 }
282
283 #[test]
284 fn real_detection_and_path_probe_do_not_panic() {
285 // Result varies by host; calling exercises the real PATH-reading wrapper.
286 let _ = detect_container_engine();
287 assert!(!binary_on_path("definitely-not-a-real-binary-xyz"));
288 }
289
290 #[test]
291 fn binary_on_path_in_handles_present_and_absent_path() {
292 // Absent PATH → never found.
293 assert!(!binary_on_path_in(None, "sh"));
294 // A PATH containing a dir with a known file resolves it. Build a PATH
295 // from a temp dir holding a marker file (portable across OSes).
296 let dir = tempfile::tempdir().unwrap();
297 std::fs::write(dir.path().join("marker"), "x").unwrap();
298 let path = std::env::join_paths([dir.path()]).unwrap();
299 assert!(binary_on_path_in(Some(path.clone()), "marker"));
300 assert!(!binary_on_path_in(Some(path), "not-there"));
301 }
302
303 #[test]
304 fn namespace_supported_matches_target() {
305 assert_eq!(namespace_supported(), cfg!(target_os = "linux"));
306 }
307
308 /// `mounts` comes from a downloaded blueprint. Mounting the engine's own
309 /// socket lets the container create a privileged container on the host -
310 /// a one-line escape - and mounting `/` makes the isolation decorative.
311 #[test]
312 fn forbidden_mounts_are_refused() {
313 for path in [
314 "/",
315 "/proc",
316 "/sys",
317 "/dev",
318 "/etc",
319 "/etc/shadow",
320 "/var/run",
321 "/var/run/docker.sock",
322 "/run/docker.sock",
323 "/var/run/podman/podman.sock",
324 ] {
325 assert!(!mount_allowed(path), "{path} must be refused");
326 }
327 }
328
329 /// Relative paths resolve against the *engine's* cwd, not ours, so they are
330 /// never what the author meant; `..` is traversal.
331 #[test]
332 fn relative_and_traversing_mounts_are_refused() {
333 for path in ["data", "./data", "/work/../etc", "../etc"] {
334 assert!(!mount_allowed(path), "{path} must be refused");
335 }
336 }
337
338 /// Ordinary project directories still mount - the denylist is about host
339 /// infrastructure, not about making the feature unusable.
340 /// The rule is POSIX, not host-native: a container path is a Linux path
341 /// wherever the daemon happens to be running. Routing it through
342 /// `std::path::Path` made every mount fail on Windows, where `/data` is not
343 /// an absolute path.
344 #[test]
345 fn mount_rules_do_not_depend_on_the_host_platform() {
346 // Absolute in POSIX terms, on every host.
347 assert!(mount_allowed("/data"));
348 assert!(mount_allowed("/home/u/project"));
349 // A Windows-style path is not a container path, and is refused.
350 assert!(!mount_allowed(r"C:\data"));
351 assert!(!mount_allowed(r"\\server\share"));
352 // Traversal is caught by segment, not by host path parsing.
353 assert!(!mount_allowed("/data/../etc"));
354 assert!(!mount_allowed("/.."));
355 assert!(!mount_allowed(".."));
356 }
357
358 #[test]
359 fn ordinary_mounts_are_allowed() {
360 for path in [
361 "/data",
362 "/home/user/project",
363 "/Users/me/code",
364 "/opt/cache",
365 // Not `/etc` itself, and not under it.
366 "/etcetera",
367 ] {
368 assert!(mount_allowed(path), "{path} should be allowed");
369 }
370 }
371
372 /// A refused mount is dropped from the argv rather than passed through.
373 #[test]
374 fn container_run_argv_drops_forbidden_mounts() {
375 let spec = ContainerRunSpec {
376 engine: "docker",
377 image: "ubuntu:24.04",
378 workdir: "/work",
379 network: true,
380 mounts: &["/var/run/docker.sock".to_string(), "/data".to_string()],
381 name: "lev-abc",
382 };
383 let argv = container_run_argv(&spec);
384 assert!(
385 !argv.iter().any(|a| a.contains("docker.sock")),
386 "the engine socket must never reach the argv: {argv:?}"
387 );
388 assert!(argv.iter().any(|a| a == "/data:/data"));
389 }
390
391 #[test]
392 fn container_run_argv_with_network() {
393 let spec = ContainerRunSpec {
394 engine: "docker",
395 image: "ubuntu:24.04",
396 workdir: "/work",
397 network: true,
398 mounts: &["/data".to_string()],
399 name: "lev-abc",
400 };
401 assert_eq!(
402 container_run_argv(&spec),
403 vec![
404 "docker",
405 "run",
406 "-d",
407 "--rm",
408 "--name",
409 "lev-abc",
410 // Hardening flags: no capabilities, no privilege regain, and
411 // bounded processes/memory. Without them the container ran as
412 // root inside with the default capability set.
413 "--cap-drop",
414 "ALL",
415 "--security-opt",
416 "no-new-privileges",
417 "--pids-limit",
418 "512",
419 "--memory",
420 "2g",
421 "-v",
422 "/work:/work",
423 "-v",
424 "/data:/data",
425 "-w",
426 "/work",
427 "ubuntu:24.04",
428 "sleep",
429 "infinity"
430 ]
431 );
432 }
433
434 #[test]
435 fn container_run_argv_no_network_uses_none() {
436 let spec = ContainerRunSpec {
437 engine: "podman",
438 image: "node:22-slim",
439 workdir: "/w",
440 network: false,
441 mounts: &[],
442 name: "lev-x",
443 };
444 let argv = container_run_argv(&spec);
445 assert_eq!(argv[0], "podman");
446 assert!(
447 argv.windows(2)
448 .any(|w| w == ["--network".to_string(), "none".to_string()])
449 );
450 }
451
452 #[test]
453 fn container_run_argv_accepts_arbitrary_engine() {
454 // Non-prescriptive: any Docker-CLI-compatible binary works.
455 let spec = ContainerRunSpec {
456 engine: "nerdctl",
457 image: "alpine",
458 workdir: "/w",
459 network: true,
460 mounts: &[],
461 name: "lev-n",
462 };
463 assert_eq!(container_run_argv(&spec)[0], "nerdctl");
464 }
465
466 #[test]
467 fn container_exec_argv_shape() {
468 assert_eq!(
469 container_exec_argv("docker", "lev-abc", "/work", "sh", "-c", "ls -la"),
470 vec![
471 "docker", "exec", "-w", "/work", "lev-abc", "sh", "-c", "ls -la"
472 ]
473 );
474 }
475
476 #[test]
477 fn container_rm_argv_shape() {
478 assert_eq!(
479 container_rm_argv("podman", "lev-abc"),
480 vec!["podman", "rm", "-f", "lev-abc"]
481 );
482 }
483
484 #[test]
485 fn namespace_argv_isolated_network() {
486 let argv = namespace_argv("sh", "-c", "whoami", false);
487 assert_eq!(argv[0], "unshare");
488 assert!(argv.contains(&"--net".to_string()));
489 assert_eq!(&argv[argv.len() - 3..], &["sh", "-c", "whoami"]);
490 }
491
492 #[test]
493 fn namespace_argv_shared_network_omits_net() {
494 let argv = namespace_argv("bash", "-c", "echo hi", true);
495 assert!(!argv.contains(&"--net".to_string()));
496 assert!(argv.contains(&"--user".to_string()));
497 }
498}