Skip to main content

mermaid_runtime/
sandbox.rs

1//! Optional OS sandboxing for model-driven shell commands.
2//!
3//! Two independent confinement dimensions, one platform-neutral facade:
4//!
5//! - A **network kill-switch** (`--no-network` / `safety.network = "deny"`).
6//!   Linux: a seccomp-BPF filter — creating an internet socket (`AF_INET` /
7//!   `AF_INET6`) dies with `SIGSYS`, while `AF_UNIX` and other local socket
8//!   domains keep working so nscd / D-Bus / X11 are unaffected. `SIGSYS` is a
9//!   distinctive, catchable signal, which the exec tool maps to a clear
10//!   "blocked by the network sandbox" outcome. macOS: a Seatbelt
11//!   `(deny network*)` sparing `AF_UNIX`, denied at use time with `EPERM`
12//!   (no signal — detection is a hedged text signature).
13//! - A **filesystem write-confinement** (`--confine-fs` /
14//!   `safety.filesystem = "project"`). Write-class access (create / write /
15//!   truncate / remove / rename) is allowed only beneath an explicit set of
16//!   directories; everything else fails with a permission error. Reads and
17//!   execution stay unrestricted. Linux: Landlock, best-effort by design — a
18//!   kernel without it (pre-5.13) degrades to a warned no-op rather than
19//!   refusing to run. macOS: Seatbelt `deny file-write*` outside the roots.
20//!
21//! Everything funnels through [`enforce`], called from the
22//! `mermaid __sandbox-exec` launcher — ordinary, single-threaded code — just
23//! before it runs the real command. On Linux the restrictions are applied to
24//! the launcher itself ([`Enforcement::SelfApplied`]) and survive `execve` and
25//! `fork`; on macOS the argv is rewritten to run under `/usr/bin/sandbox-exec`
26//! ([`Enforcement::ExecArgv`]), whose profile is likewise inherited by
27//! everything the command spawns. Platforms without a backend return `Err`
28//! when confinement was requested, so the launcher fails closed (exit 126)
29//! instead of ever running the command unconfined.
30
31use std::ffi::OsString;
32use std::path::PathBuf;
33
34/// What the caller asked the OS to confine. Both dimensions are independent;
35/// an all-off policy enforces nothing (and [`enforce`] is a no-op for it on
36/// every platform).
37#[derive(Debug, Clone, Default)]
38pub struct SandboxPolicy {
39    /// Deny network access (internet sockets; local `AF_UNIX` is spared).
40    pub deny_network: bool,
41    /// When non-empty, confine write-class filesystem access to (beneath)
42    /// these directories. Roots that don't exist are skipped, narrowing the
43    /// sandbox rather than erroring.
44    pub allowed_writes: Vec<PathBuf>,
45}
46
47/// How the platform enforced a [`SandboxPolicy`] — the contract between
48/// [`enforce`] and the `__sandbox-exec` launcher.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Enforcement {
51    /// The restrictions were installed on the *current* process (Linux
52    /// seccomp + Landlock); the caller must now exec its own argv.
53    /// `fs_enforced` is `false` when write-confinement was requested but the
54    /// kernel cannot enforce it (documented best-effort — the caller warns
55    /// and continues).
56    SelfApplied { fs_enforced: bool },
57    /// The caller must exec this rewritten argv instead (macOS: the command
58    /// wrapped in `/usr/bin/sandbox-exec`).
59    ExecArgv(Vec<OsString>),
60    /// The platform ran the child itself and this is its exit code
61    /// (Windows AppContainer follow-up; no constructor yet).
62    Ran(i32),
63}
64
65/// Enforce `policy` for the command `argv`. Called from single-threaded
66/// launcher code. Any `Err` means the requested confinement could not be
67/// applied — the caller MUST fail closed (exit 126), never run unconfined.
68pub fn enforce(policy: &SandboxPolicy, argv: &[OsString]) -> anyhow::Result<Enforcement> {
69    if !policy.deny_network && policy.allowed_writes.is_empty() {
70        // Nothing requested: nothing to enforce, on any platform.
71        return Ok(Enforcement::SelfApplied { fs_enforced: true });
72    }
73    #[cfg(target_os = "linux")]
74    {
75        use anyhow::Context as _;
76        // Self-applied: the caller execs its own argv afterwards.
77        let _ = argv;
78        // Landlock first (its setup opens the allowed dirs), then seccomp.
79        let mut fs_enforced = true;
80        if !policy.allowed_writes.is_empty() {
81            fs_enforced = linux::apply_fs_confinement(&policy.allowed_writes)
82                .context("filesystem sandbox unavailable")?;
83        }
84        if policy.deny_network {
85            linux::apply_network_killswitch().context("network sandbox unavailable")?;
86        }
87        Ok(Enforcement::SelfApplied { fs_enforced })
88    }
89    #[cfg(target_os = "macos")]
90    {
91        anyhow::ensure!(
92            macos::sandbox_exec_present(),
93            "{} not found; refusing to run the command unconfined",
94            macos::SANDBOX_EXEC
95        );
96        Ok(Enforcement::ExecArgv(macos::wrap_argv(policy, argv)))
97    }
98    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
99    {
100        let _ = argv;
101        anyhow::bail!("no OS sandbox backend on this platform; refusing to run unconfined")
102    }
103}
104
105/// Whether the network kill-switch is really available on this platform:
106/// Linux when the seccomp filter assembles (supported arch), macOS when
107/// `/usr/bin/sandbox-exec` exists, `false` everywhere else (Windows
108/// AppContainer is a follow-up). Used by `mermaid self-test` and the exec
109/// tool as a safe, fork-free probe — it installs nothing.
110pub fn network_killswitch_available() -> bool {
111    #[cfg(target_os = "linux")]
112    {
113        linux::network_filter().is_ok()
114    }
115    #[cfg(target_os = "macos")]
116    {
117        macos::sandbox_exec_present()
118    }
119    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
120    {
121        false
122    }
123}
124
125/// Whether filesystem write-confinement is really available on this platform:
126/// Linux when the Landlock ruleset assembles, macOS when
127/// `/usr/bin/sandbox-exec` exists, `false` everywhere else. Like
128/// [`network_killswitch_available`]: a safe probe that restricts nothing.
129/// (On Linux enforcement remains best-effort at apply time — a pre-Landlock
130/// kernel builds the ruleset but cannot enforce it.)
131pub fn fs_confinement_available() -> bool {
132    #[cfg(target_os = "linux")]
133    {
134        linux::fs_ruleset_builds()
135    }
136    #[cfg(target_os = "macos")]
137    {
138        macos::sandbox_exec_present()
139    }
140    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
141    {
142        false
143    }
144}
145
146/// macOS Seatbelt backend: generate an allow-default SBPL profile and rewrite
147/// the command argv to run under `/usr/bin/sandbox-exec`.
148///
149/// Profile generation and argv rewriting are PURE functions, compiled (and
150/// unit-tested) on every platform — only [`enforce`] reaches them at runtime,
151/// and only on macOS. Security invariant: no caller-controlled path is ever
152/// spliced into the profile string; paths travel exclusively through `-D`
153/// parameters that the profile references as `(param "WRn")`, so a hostile
154/// directory name cannot inject SBPL.
155#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
156mod macos {
157    use std::ffi::OsString;
158    use std::path::{Path, PathBuf};
159
160    use super::SandboxPolicy;
161
162    /// Fixed, absolute path — deliberately not resolved via `PATH`.
163    pub(super) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
164
165    pub(super) fn sandbox_exec_present() -> bool {
166        Path::new(SANDBOX_EXEC).exists()
167    }
168
169    /// Build the SBPL profile for `policy`, plus the `-D` parameters it
170    /// references (name → path). Allow-default so toolchains keep working;
171    /// only the requested dimensions are denied.
172    ///
173    /// Each existing allowed root yields a literal param (`WRn`) and, when it
174    /// differs, a canonicalized one (`WRnC`): on macOS `TMPDIR` lives under
175    /// the `/var` firmlink while Seatbelt sees `/private/var`, so matching on
176    /// only one form would deny legitimate temp writes (or under-match).
177    /// Nonexistent roots are skipped — parity with Landlock's
178    /// `path_beneath_rules`, which silently narrows the sandbox for paths it
179    /// cannot open.
180    pub(super) fn profile(policy: &SandboxPolicy) -> (String, Vec<(String, PathBuf)>) {
181        let mut sbpl = String::from("(version 1)\n(allow default)\n");
182        if policy.deny_network {
183            // Deny all network, then re-allow AF_UNIX sockets (later SBPL
184            // rules win) — parity with the Linux seccomp filter, which only
185            // kills AF_INET/AF_INET6 so D-Bus/nscd-style local IPC survives.
186            // If macOS ever rejects this filter grammar, the CI
187            // profile-compile canary fails loudly; the fallback is deleting
188            // the two `allow` lines (stricter than Linux, documented delta).
189            sbpl.push_str("(deny network*)\n");
190            sbpl.push_str("(allow network* (remote unix))\n");
191            sbpl.push_str("(allow network* (local unix))\n");
192        }
193        let mut params: Vec<(String, PathBuf)> = Vec::new();
194        if !policy.allowed_writes.is_empty() {
195            for (n, root) in policy.allowed_writes.iter().enumerate() {
196                if !root.exists() {
197                    continue;
198                }
199                params.push((format!("WR{n}"), root.clone()));
200                if let Ok(canonical) = std::fs::canonicalize(root)
201                    && canonical != *root
202                {
203                    params.push((format!("WR{n}C"), canonical));
204                }
205            }
206            if params.is_empty() {
207                // Confinement was requested but every allowed root is
208                // missing: deny all writes (Landlock parity — missing dirs
209                // narrow the sandbox) rather than emit an empty require-all.
210                sbpl.push_str("(deny file-write*)\n");
211            } else {
212                // FOOTGUN: the require-nots MUST be wrapped in `require-all`.
213                // A bare filter list ORs its members, and "not under root A
214                // OR not under root B" is true for every path once there are
215                // two roots — denying ALL writes. `require-all` makes it
216                // "outside every allowed root", which is the intent.
217                sbpl.push_str("(deny file-write* (require-all");
218                for (name, _) in &params {
219                    // Param NAMES are generated here (WRn/WRnC, never from
220                    // input); path VALUES ride the -D argv, not this string.
221                    sbpl.push_str(&format!(" (require-not (subpath (param \"{name}\")))"));
222                }
223                sbpl.push_str("))\n");
224            }
225        }
226        (sbpl, params)
227    }
228
229    /// Rewrite `argv` to run under `sandbox-exec` with the policy's profile:
230    /// `/usr/bin/sandbox-exec -p <profile> [-D WRn=<path>]... -- <argv...>`.
231    pub(super) fn wrap_argv(policy: &SandboxPolicy, argv: &[OsString]) -> Vec<OsString> {
232        let (sbpl, params) = profile(policy);
233        let mut wrapped: Vec<OsString> = vec![SANDBOX_EXEC.into(), "-p".into(), sbpl.into()];
234        for (name, value) in params {
235            wrapped.push("-D".into());
236            let mut kv = OsString::from(format!("{name}="));
237            kv.push(value.as_os_str());
238            wrapped.push(kv);
239        }
240        // `--` ends option parsing so a command starting with `-` cannot be
241        // taken for a sandbox-exec flag.
242        wrapped.push("--".into());
243        wrapped.extend(argv.iter().cloned());
244        wrapped
245    }
246
247    #[cfg(test)]
248    mod tests {
249        use super::*;
250
251        fn policy(deny_network: bool, allowed_writes: &[PathBuf]) -> SandboxPolicy {
252            SandboxPolicy {
253                deny_network,
254                allowed_writes: allowed_writes.to_vec(),
255            }
256        }
257
258        /// A fresh existing directory under the temp dir.
259        fn tempdir(tag: &str) -> PathBuf {
260            let dir = std::env::temp_dir().join(format!(
261                "mermaid-sbpl-test-{tag}-{}-{}",
262                std::process::id(),
263                std::time::SystemTime::now()
264                    .duration_since(std::time::UNIX_EPOCH)
265                    .unwrap()
266                    .as_nanos()
267            ));
268            std::fs::create_dir_all(&dir).unwrap();
269            dir
270        }
271
272        #[test]
273        fn network_denial_lines_present_iff_requested() {
274            let (with_net, _) = profile(&policy(true, &[]));
275            assert!(with_net.contains("(deny network*)"));
276            // AF_UNIX-sparing allows ship with the deny (Linux parity).
277            assert!(with_net.contains("(allow network* (remote unix))"));
278            assert!(with_net.contains("(allow network* (local unix))"));
279            // deny_network-only: no write confinement may leak into the profile.
280            assert!(!with_net.contains("file-write"));
281
282            let dir = tempdir("no-net");
283            let (without_net, _) = profile(&policy(false, std::slice::from_ref(&dir)));
284            assert!(!without_net.contains("network"));
285            let _ = std::fs::remove_dir_all(&dir);
286        }
287
288        #[test]
289        fn write_denial_nests_require_nots_under_require_all() {
290            let a = tempdir("ra-a");
291            let b = tempdir("ra-b");
292            let (sbpl, params) = profile(&policy(false, &[a.clone(), b.clone()]));
293            // Both roots exist and are already canonical-ish; at least the
294            // two literal params must be present and referenced.
295            assert!(params.iter().any(|(n, _)| n == "WR0"));
296            assert!(params.iter().any(|(n, _)| n == "WR1"));
297            // The require-nots are wrapped in ONE require-all (a bare list
298            // would OR and deny everything — see the generator comment).
299            let deny = sbpl
300                .lines()
301                .find(|l| l.starts_with("(deny file-write*"))
302                .expect("deny file-write* line");
303            assert!(deny.starts_with("(deny file-write* (require-all (require-not"));
304            assert_eq!(
305                deny.matches("(require-not (subpath (param ").count(),
306                params.len(),
307                "one require-not per emitted param: {deny}"
308            );
309            let _ = std::fs::remove_dir_all(&a);
310            let _ = std::fs::remove_dir_all(&b);
311        }
312
313        #[test]
314        fn dual_literal_and_canonical_params_when_they_differ() {
315            let real = tempdir("canon");
316            let sub = real.join("sub");
317            std::fs::create_dir_all(&sub).unwrap();
318            // `<real>/sub/..` exists but canonicalizes to `<real>` — a
319            // platform-neutral stand-in for the macOS TMPDIR firmlink
320            // (/var/... vs /private/var/...).
321            let alias = sub.join("..");
322            let (sbpl, params) = profile(&policy(false, std::slice::from_ref(&alias)));
323            let literal = params.iter().find(|(n, _)| n == "WR0").expect("literal");
324            let canonical = params.iter().find(|(n, _)| n == "WR0C").expect("canonical");
325            assert_eq!(literal.1, alias);
326            assert_ne!(canonical.1, alias);
327            assert!(sbpl.contains("(param \"WR0\")"));
328            assert!(sbpl.contains("(param \"WR0C\")"));
329            let _ = std::fs::remove_dir_all(&real);
330        }
331
332        #[test]
333        fn nonexistent_roots_are_skipped_and_all_missing_denies_all_writes() {
334            let missing = std::env::temp_dir().join("mermaid-sbpl-test-definitely-missing");
335            let (sbpl, params) = profile(&policy(false, std::slice::from_ref(&missing)));
336            assert!(params.is_empty());
337            // No surviving root: plain deny (narrowed sandbox, Landlock
338            // parity), not an empty require-all of unknown SBPL validity.
339            assert!(sbpl.contains("(deny file-write*)\n"));
340            assert!(!sbpl.contains("require-all"));
341        }
342
343        #[test]
344        fn paths_never_reach_the_profile_string() {
345            // A directory name full of SBPL metacharacters must not appear in
346            // the profile text — it rides only in the -D parameter values.
347            let evil = tempdir("evil").join("x) (allow default) (deny");
348            std::fs::create_dir_all(&evil).unwrap();
349            let (sbpl, params) = profile(&policy(true, std::slice::from_ref(&evil)));
350            assert!(!sbpl.contains("allow default) (deny"));
351            assert!(
352                params.iter().any(|(_, v)| *v == evil),
353                "the path must ride a param instead"
354            );
355            let _ = std::fs::remove_dir_all(evil.parent().unwrap());
356        }
357
358        #[test]
359        fn wrap_argv_shape_is_frozen() {
360            let dir = tempdir("argv");
361            let argv: Vec<OsString> = vec!["sh".into(), "-c".into(), "echo hi".into()];
362            let wrapped = wrap_argv(&policy(true, std::slice::from_ref(&dir)), &argv);
363            assert_eq!(wrapped[0], OsString::from(SANDBOX_EXEC));
364            assert_eq!(wrapped[1], OsString::from("-p"));
365            let profile_arg = wrapped[2].to_string_lossy();
366            assert!(profile_arg.starts_with("(version 1)\n(allow default)\n"));
367            assert_eq!(wrapped[3], OsString::from("-D"));
368            let kv = wrapped[4].to_string_lossy();
369            assert!(kv.starts_with("WR0="), "param assignment: {kv}");
370            // `--` separates sandbox-exec options from the wrapped command.
371            let sep = wrapped
372                .iter()
373                .position(|a| a == "--")
374                .expect("-- separator");
375            assert_eq!(&wrapped[sep + 1..], argv.as_slice());
376            let _ = std::fs::remove_dir_all(&dir);
377        }
378    }
379}
380
381#[cfg(target_os = "linux")]
382mod linux {
383    use std::collections::BTreeMap;
384
385    use anyhow::Context;
386    use seccompiler::{
387        BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
388        SeccompRule, apply_filter,
389    };
390
391    /// Build the "deny internet sockets" BPF program: kill the process on
392    /// `socket(AF_INET|AF_INET6, …)`, allow everything else (including
393    /// `AF_UNIX`). Comparing the low 32 bits (`Dword`) of the domain argument is
394    /// deliberate — it catches a family smuggled in the high bits, which the
395    /// kernel would still truncate to `AF_INET`.
396    pub(super) fn network_filter() -> anyhow::Result<BpfProgram> {
397        let inet = SeccompRule::new(vec![SeccompCondition::new(
398            0,
399            SeccompCmpArgLen::Dword,
400            SeccompCmpOp::Eq,
401            libc::AF_INET as u64,
402        )?])?;
403        let inet6 = SeccompRule::new(vec![SeccompCondition::new(
404            0,
405            SeccompCmpArgLen::Dword,
406            SeccompCmpOp::Eq,
407            libc::AF_INET6 as u64,
408        )?])?;
409
410        let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
411        rules.insert(libc::SYS_socket, vec![inet, inet6]);
412
413        let filter = SeccompFilter::new(
414            rules,
415            SeccompAction::Allow,       // syscalls with no matching rule: allow
416            SeccompAction::KillProcess, // an inet socket: SIGSYS-kill the process
417            std::env::consts::ARCH
418                .try_into()
419                .context("seccomp: unsupported target arch")?,
420        )
421        .context("seccomp: build network filter")?;
422
423        let program: BpfProgram = filter.try_into().context("seccomp: assemble network BPF")?;
424        Ok(program)
425    }
426
427    pub fn apply_network_killswitch() -> anyhow::Result<()> {
428        let program = network_filter()?;
429        apply_filter(&program).context("seccomp: install network filter")?;
430        Ok(())
431    }
432
433    /// Landlock ABI the write-confinement targets. V3 (kernel 6.2) rounds out
434    /// the write set with `Truncate` on top of V2's `Refer` (cross-directory
435    /// rename/link). `CompatLevel::BestEffort` degrades gracefully on older
436    /// kernels.
437    const LANDLOCK_ABI: landlock::ABI = landlock::ABI::V3;
438
439    /// Build + apply the "writes only beneath these directories" Landlock
440    /// ruleset. Only write-class access is handled, so reads and execution stay
441    /// unrestricted everywhere. Returns whether the kernel actually enforces.
442    pub(super) fn apply_fs_confinement(
443        allowed_writes: &[std::path::PathBuf],
444    ) -> anyhow::Result<bool> {
445        use landlock::{
446            AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, RulesetCreatedAttr,
447            RulesetStatus, path_beneath_rules,
448        };
449
450        let write_access = AccessFs::from_write(LANDLOCK_ABI);
451        let status = Ruleset::default()
452            .set_compatibility(CompatLevel::BestEffort)
453            .handle_access(write_access)
454            .context("landlock: handle write access")?
455            .create()
456            .context("landlock: create ruleset")?
457            // `path_beneath_rules` silently skips paths that can't be opened,
458            // so a missing allowed dir narrows the sandbox instead of erroring.
459            .add_rules(path_beneath_rules(allowed_writes, write_access))
460            .context("landlock: add write rules")?
461            .restrict_self()
462            .context("landlock: restrict self")?;
463        Ok(status.ruleset != RulesetStatus::NotEnforced)
464    }
465
466    /// Whether the confinement ruleset assembles (fork-free `self-test` probe;
467    /// creates a ruleset fd and drops it without restricting anything).
468    pub(super) fn fs_ruleset_builds() -> bool {
469        use landlock::{AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr};
470        Ruleset::default()
471            .set_compatibility(CompatLevel::BestEffort)
472            .handle_access(AccessFs::from_write(LANDLOCK_ABI))
473            .and_then(|r| r.create())
474            .is_ok()
475    }
476
477    #[cfg(test)]
478    mod tests {
479        use super::*;
480
481        /// Fork a child, install `bpf`, attempt `socket(domain, SOCK_STREAM, 0)`,
482        /// and return the child's raw wait status. The BPF is built in the
483        /// parent so the post-`fork` child only performs near-async-signal-safe
484        /// work (the seccomp syscall + `socket` + `_exit`).
485        fn child_socket_status(bpf: &BpfProgram, domain: libc::c_int) -> libc::c_int {
486            // SAFETY: single-threaded test path; the child only calls
487            // `apply_filter`, `socket`/`close`, and `_exit`.
488            unsafe {
489                let pid = libc::fork();
490                assert!(pid >= 0, "fork failed");
491                if pid == 0 {
492                    if apply_filter(bpf).is_err() {
493                        libc::_exit(77);
494                    }
495                    let fd = libc::socket(domain, libc::SOCK_STREAM, 0);
496                    if fd >= 0 {
497                        libc::close(fd);
498                    }
499                    libc::_exit(0);
500                }
501                let mut status: libc::c_int = 0;
502                let waited = libc::waitpid(pid, &mut status, 0);
503                assert_eq!(waited, pid, "waitpid failed");
504                status
505            }
506        }
507
508        #[test]
509        fn inet_socket_is_killed_with_sigsys() {
510            let bpf = network_filter().expect("build filter");
511            let status = child_socket_status(&bpf, libc::AF_INET);
512            assert!(
513                libc::WIFSIGNALED(status),
514                "AF_INET socket should be signal-killed, status={status}"
515            );
516            assert_eq!(
517                libc::WTERMSIG(status),
518                libc::SIGSYS,
519                "AF_INET socket should die with SIGSYS"
520            );
521        }
522
523        #[test]
524        fn unix_socket_is_allowed() {
525            let bpf = network_filter().expect("build filter");
526            let status = child_socket_status(&bpf, libc::AF_UNIX);
527            assert!(
528                libc::WIFEXITED(status),
529                "AF_UNIX socket should exit cleanly, status={status}"
530            );
531            assert_eq!(
532                libc::WEXITSTATUS(status),
533                0,
534                "AF_UNIX socket must be allowed under the network kill-switch"
535            );
536        }
537
538        #[test]
539        fn fs_confinement_allows_inside_and_denies_outside_writes() {
540            // Two sibling temp dirs; confinement grants writes beneath only one.
541            let base = std::env::temp_dir().join(format!(
542                "mermaid-landlock-test-{}-{}",
543                std::process::id(),
544                // Distinguish parallel test binaries reusing a pid.
545                std::time::SystemTime::now()
546                    .duration_since(std::time::UNIX_EPOCH)
547                    .unwrap()
548                    .as_nanos()
549            ));
550            let allowed = base.join("allowed");
551            let outside = base.join("outside");
552            std::fs::create_dir_all(&allowed).unwrap();
553            std::fs::create_dir_all(&outside).unwrap();
554
555            // Exit codes: 0 = enforced correctly; 42 = kernel can't enforce
556            // (skip); 10 = inside write failed; 11 = outside write succeeded;
557            // 77 = apply failed. Same fork pattern as the seccomp tests above.
558            // SAFETY: the child only runs the confinement setup, two writes,
559            // and `_exit`.
560            let status = unsafe {
561                let pid = libc::fork();
562                assert!(pid >= 0, "fork failed");
563                if pid == 0 {
564                    let code = match apply_fs_confinement(std::slice::from_ref(&allowed)) {
565                        Err(_) => 77,
566                        Ok(false) => 42,
567                        Ok(true) => {
568                            let inside_ok = std::fs::write(allowed.join("in.txt"), b"x").is_ok();
569                            let outside_ok = std::fs::write(outside.join("out.txt"), b"x").is_ok();
570                            match (inside_ok, outside_ok) {
571                                (true, false) => 0,
572                                (false, _) => 10,
573                                (true, true) => 11,
574                            }
575                        },
576                    };
577                    libc::_exit(code);
578                }
579                let mut status: libc::c_int = 0;
580                assert_eq!(libc::waitpid(pid, &mut status, 0), pid, "waitpid failed");
581                status
582            };
583
584            let _ = std::fs::remove_dir_all(&base);
585
586            assert!(
587                libc::WIFEXITED(status),
588                "child should exit, status={status}"
589            );
590            let code = libc::WEXITSTATUS(status);
591            if code == 42 {
592                eprintln!("skipping: kernel does not enforce Landlock");
593                return;
594            }
595            assert_eq!(
596                code, 0,
597                "confined child: 10 = allowed write failed, 11 = outside write \
598                 succeeded, 77 = apply failed"
599            );
600        }
601    }
602}