Skip to main content

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}