Skip to main content

sandlock_core/
context.rs

1// Fork + confinement sequence: child-side Landlock + seccomp application
2// and parent-child pipe synchronization.
3
4use std::ffi::{CStr, CString};
5use std::io;
6use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
7
8use crate::resolved::ResolvedSandbox;
9use crate::sandbox::Sandbox;
10use crate::seccomp::bpf;
11
12#[cfg(test)]
13use crate::arch;
14#[cfg(test)]
15use crate::sys::structs::{
16    AF_INET, AF_INET6, CLONE_NS_FLAGS, DEFAULT_BLOCKLIST_SYSCALLS, PR_SET_DUMPABLE,
17    SIOCGIFCONF, SIOCETHTOOL, SOCK_DGRAM, SOCK_RAW, SOCK_TYPE_MASK, TIOCLINUX, TIOCSTI,
18};
19
20// ============================================================
21// Pipe pair for parent-child synchronization
22// ============================================================
23
24/// Pipes for parent-child communication after fork().
25pub struct PipePair {
26    /// Parent reads the notif fd number written by the child.
27    pub notif_r: OwnedFd,
28    /// Child writes the notif fd number to the parent.
29    pub notif_w: OwnedFd,
30    /// Child reads the "supervisor ready" signal from the parent.
31    pub ready_r: OwnedFd,
32    /// Parent writes the "supervisor ready" signal to the child.
33    pub ready_w: OwnedFd,
34}
35
36impl PipePair {
37    /// Create two pipe pairs using `pipe2(O_CLOEXEC)`.
38    pub fn new() -> io::Result<Self> {
39        let mut notif_fds = [0i32; 2];
40        let mut ready_fds = [0i32; 2];
41
42        // SAFETY: pipe2 with valid pointers and O_CLOEXEC
43        let ret = unsafe { libc::pipe2(notif_fds.as_mut_ptr(), libc::O_CLOEXEC) };
44        if ret < 0 {
45            return Err(io::Error::last_os_error());
46        }
47
48        let ret = unsafe { libc::pipe2(ready_fds.as_mut_ptr(), libc::O_CLOEXEC) };
49        if ret < 0 {
50            // Close the first pair on failure
51            unsafe {
52                libc::close(notif_fds[0]);
53                libc::close(notif_fds[1]);
54            }
55            return Err(io::Error::last_os_error());
56        }
57
58        // SAFETY: pipe2 returned valid fds
59        Ok(PipePair {
60            notif_r: unsafe { OwnedFd::from_raw_fd(notif_fds[0]) },
61            notif_w: unsafe { OwnedFd::from_raw_fd(notif_fds[1]) },
62            ready_r: unsafe { OwnedFd::from_raw_fd(ready_fds[0]) },
63            ready_w: unsafe { OwnedFd::from_raw_fd(ready_fds[1]) },
64        })
65    }
66}
67
68// ============================================================
69// Pipe I/O helpers
70// ============================================================
71
72/// Write a `u32` as 4 little-endian bytes to a raw fd.
73pub(crate) fn write_u32_fd(fd: RawFd, val: u32) -> io::Result<()> {
74    let buf = val.to_le_bytes();
75    let mut written = 0usize;
76    while written < 4 {
77        let ret = unsafe {
78            libc::write(
79                fd,
80                buf[written..].as_ptr() as *const libc::c_void,
81                4 - written,
82            )
83        };
84        if ret < 0 {
85            return Err(io::Error::last_os_error());
86        }
87        written += ret as usize;
88    }
89    Ok(())
90}
91
92/// Read a `u32` (4 little-endian bytes, blocking) from a raw fd.
93pub(crate) fn read_u32_fd(fd: RawFd) -> io::Result<u32> {
94    let mut buf = [0u8; 4];
95    let mut total = 0usize;
96    while total < 4 {
97        let ret = unsafe {
98            libc::read(
99                fd,
100                buf[total..].as_mut_ptr() as *mut libc::c_void,
101                4 - total,
102            )
103        };
104        if ret < 0 {
105            return Err(io::Error::last_os_error());
106        }
107        if ret == 0 {
108            return Err(io::Error::new(
109                io::ErrorKind::UnexpectedEof,
110                "pipe closed before 4 bytes read",
111            ));
112        }
113        total += ret as usize;
114    }
115    Ok(u32::from_le_bytes(buf))
116}
117
118#[cfg(test)]
119use crate::seccomp::syscall::syscall_name_to_nr;
120
121// ============================================================
122// Sandbox -> seccomp plan
123// ============================================================
124
125pub(crate) use crate::seccomp_plan::{arg_filters_resolved, notif_syscalls_resolved};
126
127pub fn notif_syscalls(policy: &Sandbox, sandbox_name: Option<&str>) -> Vec<u32> {
128    crate::seccomp_plan::notif_syscalls(policy, sandbox_name)
129}
130
131pub fn no_supervisor_blocklist_syscall_numbers(policy: &Sandbox) -> Vec<u32> {
132    crate::seccomp_plan::no_supervisor_blocklist_syscall_numbers(policy)
133}
134
135pub fn blocklist_syscall_numbers(policy: &Sandbox) -> Vec<u32> {
136    crate::seccomp_plan::blocklist_syscall_numbers(policy)
137}
138
139pub fn arg_filters(policy: &Sandbox) -> Vec<crate::sys::structs::SockFilter> {
140    crate::seccomp_plan::arg_filters(policy)
141}
142
143// ============================================================
144// Close fds above threshold
145// ============================================================
146
147/// Close all file descriptors above `min_fd`, except those in `keep`.
148fn close_fds_above(min_fd: RawFd, keep: &[RawFd]) {
149    // Read /proc/self/fd to enumerate open fds.
150    // Collect all fd numbers first, then close them after dropping the directory
151    // iterator. This avoids closing the directory fd during iteration.
152    let fds_to_close: Vec<RawFd> = {
153        let dir = match std::fs::read_dir("/proc/self/fd") {
154            Ok(d) => d,
155            Err(_) => return,
156        };
157        dir.flatten()
158            .filter_map(|entry| {
159                entry.file_name().into_string().ok()
160                    .and_then(|name| name.parse::<RawFd>().ok())
161            })
162            .filter(|&fd| fd > min_fd && !keep.contains(&fd))
163            .collect()
164    };
165    // The directory is now closed; safe to close the collected fds.
166    for fd in fds_to_close {
167        unsafe { libc::close(fd) };
168    }
169}
170
171// ============================================================
172// User-namespace uid/gid mapping helpers
173// ============================================================
174
175/// Write uid/gid maps for an unprivileged user namespace.
176/// `real_uid`/`real_gid` must be captured *before* unshare(CLONE_NEWUSER),
177/// since getuid()/getgid() return the overflow id (65534) after unshare.
178/// `target_uid`/`target_gid` are the UIDs visible inside the namespace.
179fn write_id_maps(real_uid: u32, real_gid: u32, target_uid: u32, target_gid: u32) {
180    let _ = std::fs::write("/proc/self/uid_map", format!("{} {} 1\n", target_uid, real_uid));
181    let _ = std::fs::write("/proc/self/setgroups", "deny\n");
182    let _ = std::fs::write("/proc/self/gid_map", format!("{} {} 1\n", target_gid, real_gid));
183}
184
185// ============================================================
186// Child-side confinement (never returns)
187// ============================================================
188
189/// Arguments threaded from the parent's `do_spawn` into the child-side
190/// `confine_child`.  Packed into a struct because `confine_child` historically
191/// grew to seven positional parameters and a struct keeps the call site
192/// readable when new flags get added (e.g. `extra_syscalls` for user
193/// handlers).  Lifetimes tie everything to the parent's stack frame — the
194/// child never outlives the fork point because `confine_child` either execs
195/// or exits.
196/// The terminal action `confine_child` performs after confinement is installed.
197/// Exactly one of the two: there is no longer a "command plus optional override".
198pub(crate) enum ChildEntry<'a> {
199    /// `execve` this command (the normal path). argv[0] becomes the process name.
200    Exec(&'a [CString]),
201    /// Run this function in-process, with the process named `name`. Used for the
202    /// OCI in-sandbox PID-1: the child is a fork of the supervisor so the code is
203    /// already mapped, nothing is exec'd, and Landlock has no execve to
204    /// authorize. `run` must not return; `confine_child` `_exit(0)`s if it does.
205    InProcess { name: &'a CStr, run: fn() },
206}
207
208pub(crate) struct ChildSpawnArgs<'a> {
209    pub sandbox: &'a Sandbox,
210    /// Terminal action after confinement: `execve` a command or run a fn
211    /// in-process. See [`ChildEntry`].
212    pub entry: ChildEntry<'a>,
213    pub pipes: &'a PipePair,
214    /// Skip the user-notification supervisor: child installs a kernel-only
215    /// deny filter, parent reads `notif_fd_num = 0` and never starts a
216    /// supervisor. Mirrors `Sandbox::no_supervisor`.
217    pub no_supervisor: bool,
218    pub keep_fds: &'a [RawFd],
219    /// Sandbox instance name. When set, it is also exposed as the
220    /// sandbox's virtual hostname.
221    pub sandbox_name: Option<&'a str>,
222    /// Syscall numbers for which the parent registered user `Handler`s.
223    /// Merged into the child's BPF notif list so the kernel actually
224    /// raises USER_NOTIF for them.
225    pub extra_syscalls: &'a [u32],
226    /// PID of the parent process captured before fork. Used to detect
227    /// parent death in the child without assuming PID 1 is always init
228    /// (incorrect in containers where the entrypoint runs as PID 1).
229    pub parent_pid: libc::pid_t,
230}
231
232/// Set the calling thread/process name (`/proc/<pid>/comm`, shown by `ps`). The
233/// kernel truncates to 15 bytes + NUL. Used for the in-process PID-1, which has
234/// no `execve` to set its name from argv[0].
235fn set_proc_name(name: &CStr) {
236    unsafe { libc::prctl(libc::PR_SET_NAME, name.as_ptr() as libc::c_ulong, 0, 0, 0) };
237}
238
239/// Apply irreversible confinement (Landlock + seccomp), then either `execve` the
240/// command or run an in-process entrypoint, per [`ChildEntry`].
241///
242/// This function **never returns**: on success it execs or runs the entrypoint
243/// (which `_exit`s); on any error it `_exit(127)`s.
244pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
245    let ChildSpawnArgs {
246        sandbox,
247        entry,
248        pipes,
249        no_supervisor,
250        keep_fds,
251        sandbox_name,
252        extra_syscalls,
253        parent_pid,
254    } = args;
255    // Helper: abort child on error. Includes the OS error automatically.
256    macro_rules! fail {
257        ($msg:expr) => {{
258            let err = std::io::Error::last_os_error();
259            let _ = write!(std::io::stderr(), "sandlock child: {}: {}\n", $msg, err);
260            unsafe { libc::_exit(127) };
261        }};
262    }
263
264    use std::io::Write;
265
266    // 1. New process group
267    if unsafe { libc::setpgid(0, 0) } != 0 {
268        fail!("setpgid");
269    }
270
271    // 1b. If stdin is a terminal, become the foreground process group
272    //     so interactive shells can read from the TTY.
273    //     Must ignore SIGTTOU first — a background pgrp calling tcsetpgrp
274    //     gets stopped by SIGTTOU otherwise.
275    if unsafe { libc::isatty(0) } == 1 {
276        unsafe {
277            libc::signal(libc::SIGTTOU, libc::SIG_IGN);
278            libc::tcsetpgrp(0, libc::getpgrp());
279            libc::signal(libc::SIGTTOU, libc::SIG_DFL);
280        }
281    }
282
283    // 2. Die if parent exits
284    if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) } != 0 {
285        fail!("prctl(PR_SET_PDEATHSIG)");
286    }
287
288    // 3. Check parent didn't die between fork and prctl.
289    // Compare against the actual parent PID captured before fork rather than
290    // hardcoding 1, since containers often run the entrypoint as PID 1 and a
291    // child forked from it legitimately has getppid() == 1.
292    if unsafe { libc::getppid() } != parent_pid {
293        fail!("parent died before confinement");
294    }
295
296    // 4. Optional: disable ASLR
297    if sandbox.no_randomize_memory {
298        const ADDR_NO_RANDOMIZE: libc::c_ulong = 0x0040000;
299        // Read current personality first (0xffffffff = query), then OR in the flag.
300        let current = unsafe { libc::personality(0xffffffff) };
301        if current == -1 {
302            fail!("personality(query)");
303        }
304        if unsafe { libc::personality(current as libc::c_ulong | ADDR_NO_RANDOMIZE) } == -1 {
305            fail!("personality(ADDR_NO_RANDOMIZE)");
306        }
307    }
308
309    // 4b. Optional: CPU core binding
310    if let Some(ref cores) = sandbox.cpu_cores {
311        if !cores.is_empty() {
312            let mut set = unsafe { std::mem::zeroed::<libc::cpu_set_t>() };
313            unsafe { libc::CPU_ZERO(&mut set) };
314            for &core in cores {
315                unsafe { libc::CPU_SET(core as usize, &mut set) };
316            }
317            if unsafe {
318                libc::sched_setaffinity(
319                    0,
320                    std::mem::size_of::<libc::cpu_set_t>(),
321                    &set,
322                )
323            } != 0
324            {
325                fail!("sched_setaffinity");
326            }
327        }
328    }
329
330    // 5. Optional: disable THP
331    if sandbox.no_huge_pages {
332        if unsafe { libc::prctl(libc::PR_SET_THP_DISABLE, 1, 0, 0, 0) } != 0 {
333            fail!("prctl(PR_SET_THP_DISABLE)");
334        }
335    }
336
337    // 5c. Optional: disable core dumps
338    if sandbox.no_coredump {
339        // Set RLIMIT_CORE to 0 — the kernel will not write a core file.
340        // We intentionally do NOT call prctl(PR_SET_DUMPABLE, 0) because
341        // that would break pidfd_getfd which the supervisor needs.
342        // The seccomp filter already blocks the child from calling
343        // prctl(PR_SET_DUMPABLE, ...) so it can't re-enable it.
344        let rlim = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
345        if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim) } != 0 {
346            fail!("setrlimit(RLIMIT_CORE, 0)");
347        }
348    }
349
350    // Capture real uid/gid before any unshare (after unshare they become 65534)
351    let real_uid = unsafe { libc::getuid() };
352    let real_gid = unsafe { libc::getgid() };
353
354    // 5b. User namespace for --user (run-as uid/gid) mapping.
355    //
356    // Skip entirely when the requested identity already matches the current
357    // uid/gid: there's no point unsharing a user namespace to map an identity
358    // the process already has, and skipping avoids imposing an
359    // unprivileged-userns requirement on callers that don't need a remap.
360    if let Some(run_as) = sandbox.user {
361        if run_as.uid != real_uid || run_as.gid != real_gid {
362            if unsafe { libc::unshare(libc::CLONE_NEWUSER) } != 0 {
363                fail!("unshare(CLONE_NEWUSER)");
364            }
365            write_id_maps(real_uid, real_gid, run_as.uid, run_as.gid);
366        }
367    }
368
369    // 6. Optional: change working directory
370    // cwd controls where the child starts; workdir is only for COW
371    let effective_cwd = if let Some(ref cwd) = sandbox.cwd {
372        if let Some(ref chroot_root) = sandbox.chroot {
373            Some(chroot_root.join(cwd.strip_prefix("/").unwrap_or(cwd)))
374        } else {
375            Some(cwd.clone())
376        }
377    } else if let Some(ref chroot_root) = sandbox.chroot {
378        // Default to chroot root
379        Some(chroot_root.to_path_buf())
380    } else if let Some(ref workdir) = sandbox.workdir {
381        // Default to workdir when set (COW working directory)
382        Some(workdir.clone())
383    } else {
384        None
385    };
386
387    if let Some(ref cwd) = effective_cwd {
388        let c_path = match CString::new(cwd.as_os_str().as_encoded_bytes()) {
389            Ok(c) => c,
390            Err(_) => fail!("invalid cwd path"),
391        };
392        if unsafe { libc::chdir(c_path.as_ptr()) } != 0 {
393            fail!("chdir");
394        }
395    }
396
397    // 7. Set NO_NEW_PRIVS (required for both Landlock and seccomp without CAP_SYS_ADMIN)
398    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
399        fail!("prctl(PR_SET_NO_NEW_PRIVS)");
400    }
401
402    // 8. Apply Landlock confinement (IRREVERSIBLE)
403    if let Err(e) = crate::landlock::confine(sandbox) {
404        fail!(format!("landlock: {}", e));
405    }
406
407    // 9. Assemble and install seccomp filter (IRREVERSIBLE)
408    let handler_syscalls: Vec<i64> = extra_syscalls.iter().map(|&nr| nr as i64).collect();
409    let resolved = ResolvedSandbox::from_sandbox(sandbox, sandbox_name, &handler_syscalls);
410    let args = arg_filters_resolved(&resolved);
411    let mut keep_fd: i32 = -1;
412
413    if no_supervisor {
414        // No-supervisor mode: deny-only kernel filter, no NEW_LISTENER.
415        // BPF filters are ANDed by the kernel, so an outer filter (from a
416        // wrapping sandbox) keeps tightening this layer too.
417        //
418        // Uses the relaxed `no_supervisor_blocklist_syscall_numbers` deny
419        // list (which leaves `ptrace`, `unshare`, `process_vm_*`, etc.
420        // alone) so an inner full-supervisor sandlock nested under this
421        // one still has the syscalls its supervisor needs.
422        let deny = no_supervisor_blocklist_syscall_numbers(sandbox);
423        let filter = match bpf::assemble_filter(&[], &deny, &args) {
424            Ok(f) => f,
425            Err(e) => fail!(format!("seccomp assemble: {}", e)),
426        };
427        if let Err(e) = bpf::install_deny_filter(&filter) {
428            fail!(format!("seccomp deny filter: {}", e));
429        }
430        // fd=0 tells the parent there's no supervisor to attach to.
431        if let Err(e) = write_u32_fd(pipes.notif_w.as_raw_fd(), 0) {
432            fail!(format!("write no-supervisor signal: {}", e));
433        }
434    } else {
435        let deny = blocklist_syscall_numbers(sandbox);
436        // First-level sandbox: notif + deny filter with NEW_LISTENER.
437        //
438        // Caller-supplied handlers must have their syscalls registered in
439        // the BPF filter, otherwise the kernel never raises a notification for
440        // them and the handler silently never fires.  We merge `extra_syscalls`
441        // into the notif list and dedup so each syscall produces exactly one
442        // JEQ in the assembled program.
443        let mut notif = notif_syscalls_resolved(&resolved);
444        if !extra_syscalls.is_empty() {
445            notif.extend_from_slice(extra_syscalls);
446        }
447        notif.sort_unstable();
448        notif.dedup();
449        let filter = match bpf::assemble_filter(&notif, &deny, &args) {
450            Ok(f) => f,
451            Err(e) => fail!(format!("seccomp assemble: {}", e)),
452        };
453        let notif_fd = match bpf::install_filter(&filter) {
454            Ok(fd) => fd,
455            Err(e) => {
456                // EBUSY here means another seccomp filter on this task already
457                // owns the SECCOMP_FILTER_FLAG_NEW_LISTENER slot. The kernel
458                // permits at most one listener per task — to nest, opt this
459                // sandbox out of the supervisor via `Sandbox::no_supervisor`
460                // (or the CLI's `--no-supervisor` flag).
461                if e.raw_os_error() == Some(libc::EBUSY) {
462                    let _ = write!(
463                        std::io::stderr(),
464                        "sandlock child: seccomp install: {} (an outer sandbox already owns the \
465                         seccomp listener; pass --no-supervisor or Sandbox::no_supervisor(true) \
466                         on this sandbox to nest)\n",
467                        e,
468                    );
469                    unsafe { libc::_exit(127) };
470                }
471                fail!(format!("seccomp install: {}", e));
472            }
473        };
474        keep_fd = notif_fd.as_raw_fd();
475        if let Err(e) = write_u32_fd(pipes.notif_w.as_raw_fd(), keep_fd as u32) {
476            fail!(format!("write notif fd: {}", e));
477        }
478        std::mem::forget(notif_fd);
479    }
480
481    // 10. Wait for parent to signal ready
482    match read_u32_fd(pipes.ready_r.as_raw_fd()) {
483        Ok(_) => {}
484        Err(e) => fail!(format!("read ready signal: {}", e)),
485    }
486
487    // 12. Close all fds above stderr (always on for isolation)
488    let mut fds_to_keep: Vec<RawFd> = keep_fds.to_vec();
489    if keep_fd >= 0 {
490        fds_to_keep.push(keep_fd);
491    }
492    close_fds_above(2, &fds_to_keep);
493
494    // 13. Apply environment
495    if sandbox.clean_env {
496        // Clear all env vars first
497        for (key, _) in std::env::vars_os() {
498            std::env::remove_var(&key);
499        }
500    }
501    // Remove env vars whose value was loaded into the supervisor as a credential
502    // source, so the agent can't read the real secret straight from its own
503    // environment (this is the child; the mutation is process-local and post-fork).
504    // This runs *before* applying `sandbox.env`, so the inherited real secret is
505    // dropped but a deliberate placeholder the user passes (e.g.
506    // `--env OPENAI_API_KEY=dummy`, which SDKs need set to start) still survives.
507    for name in &sandbox.inject_env_strip {
508        std::env::remove_var(name);
509    }
510    for (key, value) in &sandbox.env {
511        std::env::set_var(key, value);
512    }
513
514    // 13b. GPU device visibility
515    if let Some(ref devices) = sandbox.gpu_devices {
516        if !devices.is_empty() {
517            let vis = devices.iter().map(|d| d.to_string()).collect::<Vec<_>>().join(",");
518            std::env::set_var("CUDA_VISIBLE_DEVICES", &vis);
519            std::env::set_var("ROCR_VISIBLE_DEVICES", &vis);
520        }
521        // Empty list = all GPUs visible, don't set env vars
522    }
523
524    // 14. Terminal action: run the in-process entrypoint, or fall through to
525    // execve the command. The in-process arm diverges (`_exit`), so the match
526    // yields the command slice only on the `Exec` path.
527    let cmd: &[CString] = match entry {
528        ChildEntry::InProcess { name, run } => {
529            // Name the PID-1 so ps / /proc/<pid>/comm read correctly: there is
530            // no execve here to set argv[0]. The child is a fork of the
531            // supervisor, so `run`'s code is already mapped; running it directly
532            // avoids an execve that Landlock would otherwise have to authorize.
533            set_proc_name(name);
534            run();
535            unsafe { libc::_exit(0) };
536        }
537        ChildEntry::Exec(cmd) => cmd,
538    };
539
540    // 14. exec
541    debug_assert!(!cmd.is_empty(), "cmd must not be empty");
542    let argv_ptrs: Vec<*const libc::c_char> = cmd
543        .iter()
544        .map(|s| s.as_ptr())
545        .chain(std::iter::once(std::ptr::null()))
546        .collect();
547
548    if sandbox.chroot.is_some() {
549        // With chroot the seccomp handler rewrites the filename to a host path
550        // (or /proc/self/fd/N).  Pass a separate PATH_MAX buffer as the `file`
551        // argument so the rewrite does not corrupt argv[0] — which must stay as
552        // the original command name (e.g. busybox uses argv[0] for applet
553        // detection).  execvp still handles PATH lookup for bare command names.
554        let mut exec_path = vec![0u8; libc::PATH_MAX as usize];
555        let orig = cmd[0].as_bytes_with_nul();
556        exec_path[..orig.len()].copy_from_slice(orig);
557
558        unsafe {
559            libc::execvp(
560                exec_path.as_ptr() as *const libc::c_char,
561                argv_ptrs.as_ptr(),
562            )
563        };
564    } else {
565        unsafe { libc::execvp(argv_ptrs[0], argv_ptrs.as_ptr()) };
566    }
567
568    // If we get here, exec failed
569    fail!(format!("execvp '{}'", cmd[0].to_string_lossy()));
570}
571
572// ============================================================
573// Tests
574// ============================================================
575
576#[cfg(test)]
577mod tests;