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(¬if, &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;