Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
99bda46
pgroup: signal groups by number on kernels without pidfd group signals
congwang-mk Oct 15, 2026
ec70b38
reaper: add a session reaper that names main's process group
congwang-mk Oct 15, 2026
21c25ba
sandbox: run every sandbox under a session reaper
congwang-mk Oct 15, 2026
2f8aee6
reaper: kill the session when its owner goes away
congwang-mk Oct 15, 2026
2464823
sandbox: keep background jobs supervised after main exits
congwang-mk Oct 15, 2026
b9d0191
ffi: separate main-process waits from session shutdown
congwang-mk Oct 15, 2026
95f0b98
python: wait for main, shut down the session explicitly
congwang-mk Oct 15, 2026
087cbb1
go: wait for main, shut down the session explicitly
congwang-mk Oct 15, 2026
9018336
cli: kill and list sessions through the supervisor
congwang-mk Oct 15, 2026
ac96a51
sandbox: kill remaining jobs when a sandbox is dropped after wait()
congwang-mk Oct 15, 2026
6349967
learn: end the whole session on timeout
congwang-mk Oct 15, 2026
1891e59
sandbox: detect exec completion through a child-owned exec pipe
congwang-mk Oct 16, 2026
10479d0
sandbox: refuse to checkpoint once main has exited
congwang-mk Oct 16, 2026
793fec7
sandbox: report main's pid only until it exits
congwang-mk Oct 16, 2026
36f06d4
sandbox: read main's exit status without a background task
congwang-mk Oct 16, 2026
309b58c
oci: drop the killpg fallback on the container's init pid
congwang-mk Oct 16, 2026
5d04f4e
sandbox: return the terminal from any sandbox group on shutdown
congwang-mk Oct 16, 2026
52e0734
cli: make ps exact about main and show what a session has left
congwang-mk Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,12 +617,9 @@ pub async fn run(args: LearnArgs) -> Result<()> {

eprintln!("sandlock learn: observing {cmd_str} ...");

// Use the three-step lifecycle (create/start/wait) so we can get the child
// PID from sandbox.pid() and sample /proc/<pid> for resource peaks.
let mut sandbox = policy;
sandbox.create_interactive(&cmd_refs).await
.map_err(|e| anyhow!("sandbox error: {e}"))?;
let child_pid = sandbox.pid().expect("child pid after create") as u32;
sandbox.start()
.map_err(|e| anyhow!("sandbox error: {e}"))?;

Expand All @@ -632,25 +629,24 @@ pub async fn run(args: LearnArgs) -> Result<()> {
match tokio::time::timeout(deadline, sandbox.wait()).await {
Ok(r) => {
let result = r.map_err(|e| anyhow!("sandbox error: {e}"))?;
require_clean_exit(&result.exit_status)?;
require_clean_exit(&result)?;
false
}
Err(_elapsed) => {
// Timeout: kill the child, drain the supervisor, write a partial profile.
// The shutdown below kills the session; the profile is partial.
eprintln!("sandlock learn: timeout after {secs}s, killing process");
unsafe { libc::kill(child_pid as i32, libc::SIGKILL); }
// Drain without timeout so the supervisor releases its resources cleanly.
let _ = sandbox.wait().await;
true
}
}
} else {
let result = sandbox.wait().await
.map_err(|e| anyhow!("sandbox error: {e}"))?;
require_clean_exit(&result.exit_status)?;
require_clean_exit(&result)?;
false
};

sandbox.shutdown().await.map_err(|e| anyhow!("sandbox error: {e}"))?;

if timed_out {
eprintln!("sandlock learn: writing partial profile from observations before timeout");
}
Expand Down
103 changes: 63 additions & 40 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,23 +285,35 @@ async fn main() -> Result<()> {
} else {
println!("NAME PID UPTIME STATUS PORTS CMD");
for name in &names {
// The pids need no answer from the supervisor; the rest
// does, and a supervisor that does not answer is still
// alive because its socket is.
let (status, ports) = match sandlock_core::control::sandbox_info(name) {
Ok(info) => (
info.mode.unwrap_or_else(|| "running".to_string()),
query_ports(name),
),
Err(_) => ("unresponsive".to_string(), "?".to_string()),
};
let (pid, uptime, cmd) = match sandlock_core::control::sandbox_pids(name) {
Ok(pids) => (
pids.child.to_string(),
proc_uptime(pids.child).unwrap_or_else(|| "?".to_string()),
proc_cmdline(pids.child).unwrap_or_else(|| "?".to_string()),
// The registry's main pid outlives main. A pidfd opened before
// the supervisor vouches main is alive names main, and /proc
// is trusted only if main is still alive after reading it.
let pid = sandlock_core::control::sandbox_pids(name).map(|p| p.child);
let main_fd = pid.as_ref().ok().and_then(|&pid| pidfd_open(pid));
let (status, ports, pid, uptime, cmd) = match (sandlock_core::control::sandbox_info(name), pid) {
(Ok(info), Ok(pid)) if !info.main_exited => {
let uptime = proc_uptime(pid);
let cmd = proc_cmdline(pid);
if main_fd.as_ref().is_some_and(|fd| !pidfd_exited(fd)) {
(
info.mode.unwrap_or_else(|| "running".to_string()),
query_ports(name),
pid.to_string(),
uptime.unwrap_or_else(|| "?".to_string()),
cmd.unwrap_or_else(|| "?".to_string()),
)
} else {
main_exited_row(name, info)
}
}
(Ok(info), _) if info.main_exited => main_exited_row(name, info),
(info, pid) => (
if info.is_ok() { "running" } else { "unresponsive" }.to_string(),
"?".to_string(),
pid.map_or_else(|_| "?".to_string(), |p| p.to_string()),
"?".to_string(),
"?".to_string(),
),
Err(_) => ("?".to_string(), "?".to_string(), "?".to_string()),
};
println!(
"{:<32} {:>8} {:>12} {:<10} {:<24} {}",
Expand Down Expand Up @@ -358,30 +370,11 @@ async fn main() -> Result<()> {
eprintln!("sandlock: {e}");
std::process::exit(1);
}
// Both pids come from the kernel, so this works on a supervisor
// that is stopped or wedged.
let pids = match sandlock_core::control::sandbox_pids(&name) {
Ok(p) => p,
Err(e) => {
eprintln!("sandlock: {}", e);
std::process::exit(1);
}
};
// Groups the sandbox created are known only to the supervisor,
// and a wedged one cannot answer, so the child's own group is
// signalled here regardless. The supervisor may sit in a
// different group, so signal it directly too.
let _ = sandlock_core::control::send_control_request(
&name,
"kill",
serde_json::Value::Object(Default::default()),
);
unsafe { libc::killpg(pids.child, libc::SIGKILL) };
unsafe { libc::kill(pids.supervisor, libc::SIGKILL) };
println!(
"Killed sandbox '{}' (child PID {}, supervisor PID {})",
name, pids.child, pids.supervisor
);
if let Err(e) = sandlock_core::control::kill_sandbox(&name) {
eprintln!("sandlock: kill '{}': {}", name, e);
std::process::exit(1);
}
println!("Killed sandbox '{}'", name);
}

Command::Check => {
Expand Down Expand Up @@ -956,6 +949,36 @@ fn validate_cli_name(name: &str) -> Result<(), String> {
/// Query the control socket for the virtual鈫抮eal port map, returning a
/// compact display string or `"-"` when the socket is missing (e.g.
/// `--no-supervisor`).
fn main_exited_row(
name: &str,
info: sandlock_core::control::SandboxInfo,
) -> (String, String, String, String, String) {
let cmd = match info.live_groups {
0 => "(main exited, idle)".to_string(),
1 => "(main exited, 1 group)".to_string(),
n => format!("(main exited, {n} groups)"),
};
(
info.mode.unwrap_or_else(|| "running".to_string()),
query_ports(name),
"-".to_string(),
"-".to_string(),
cmd,
)
}

fn pidfd_open(pid: i32) -> Option<std::os::fd::OwnedFd> {
use std::os::fd::FromRawFd;
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
(fd >= 0).then(|| unsafe { std::os::fd::OwnedFd::from_raw_fd(fd as i32) })
}

fn pidfd_exited(fd: &std::os::fd::OwnedFd) -> bool {
use std::os::fd::AsRawFd;
let mut poll = libc::pollfd { fd: fd.as_raw_fd(), events: libc::POLLIN, revents: 0 };
unsafe { libc::poll(&mut poll, 1, 0) != 0 }
}

fn query_ports(name: &str) -> String {
use sandlock_core::control::send_control_request;
match send_control_request(name, "ports", serde_json::Value::Object(Default::default())) {
Expand Down
23 changes: 23 additions & 0 deletions crates/sandlock-cli/tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,29 @@ fn test_kill_stops_sandbox() {
}
}

#[test]
fn test_kill_stops_sandbox_without_supervisor() {
let name = format!("test-kill-nosup-{}", std::process::id());
let mut args = sandlock_sleep_args(&name);
args.insert(1, "--no-supervisor".into());
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_sandlock"))
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn sandlock");
if let Err(e) = wait_for_sandbox(&mut child, &name) {
panic!("{}", e);
}
let out = sandlock_bin().args(["kill", &name]).output().expect("sandlock kill");
assert!(
out.status.success(),
"kill should succeed without a supervisor: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!child.wait().expect("wait for killed child").success());
}

#[test]
fn test_kill_nonexistent_sandbox() {
let out = sandlock_bin()
Expand Down
24 changes: 24 additions & 0 deletions crates/sandlock-cli/tests/learn_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,3 +1151,27 @@ fn test_learn_merge_preserves_home_variable() {
assert!(!reads.iter().any(|r| r.starts_with(&format!("{home}/.config"))),
"merge added a machine-specific duplicate of ${{HOME}}/.config: {reads:?}");
}

/// A timeout ends the whole session, background jobs included, and still
/// writes what was observed before it.
#[test]
fn test_learn_timeout_ends_the_session() {
// A sleep length no other process uses, so the job can be found afterwards.
let marker = format!("{}.{}", 3000 + std::process::id() % 1000, std::process::id());
let cmd = format!("cat /etc/hostname; sleep {marker} & sleep 60");
let started = std::time::Instant::now();
let output = sandlock_bin()
.args(["learn", "--timeout", "1", "--", "sh", "-c", &cmd])
.output()
.expect("failed to run sandlock learn");
assert!(started.elapsed() < std::time::Duration::from_secs(30), "learn ignored its timeout");
let stdout = String::from_utf8_lossy(&output.stdout);
let read_line = stdout.lines().find(|l| l.starts_with("read = [")).unwrap_or("");
assert!(
read_line.contains("/etc/hostname"),
"expected a partial profile with /etc/hostname, got: {stdout}; stderr={}",
String::from_utf8_lossy(&output.stderr),
);
let survivors = Command::new("pgrep").args(["-f", &format!("sleep {marker}")]).output().unwrap();
assert!(survivors.stdout.is_empty(), "the background job outlived the timeout");
}
49 changes: 29 additions & 20 deletions crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ impl PipePair {
}
}

/// Written on the exec pipe right before `execve`, which closes the pipe on
/// success; `EXEC_FAILED` follows if `execve` returns.
pub(crate) const EXEC_REACHED: u8 = b'e';
pub(crate) const EXEC_FAILED: u8 = b'f';

fn mark_exec(exec_w: RawFd, mark: u8) {
unsafe { libc::write(exec_w, [mark].as_ptr().cast(), 1) };
}

// ============================================================
// Pipe I/O helpers
// ============================================================
Expand Down Expand Up @@ -237,10 +246,6 @@ pub(crate) struct ChildSpawnArgs<'a> {
/// Merged into the child's BPF notif list so the kernel actually
/// raises USER_NOTIF for them.
pub extra_syscalls: &'a [u32],
/// PID of the parent process captured before fork. Used to detect
/// parent death in the child without assuming PID 1 is always init
/// (incorrect in containers where the entrypoint runs as PID 1).
pub parent_pid: libc::pid_t,
/// Make the child the terminal's foreground process group before exec.
/// Only interactive (fully inherited) stdio wants this; a captured or
/// piped run taking the foreground demotes the embedding process to a
Expand Down Expand Up @@ -291,7 +296,6 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
keep_fds,
sandbox_name,
extra_syscalls,
parent_pid,
foreground,
} = args;
// Helper: abort child on error. Includes the OS error automatically.
Expand All @@ -305,6 +309,14 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {

use std::io::Write;

// Created here rather than before fork so that no sibling forked by the
// parent inherits the write end and holds back its EOF.
let mut exec_fds = [0i32; 2];
if unsafe { libc::pipe2(exec_fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 {
fail!("exec pipe");
}
let [exec_r, exec_w] = exec_fds;

// 1. Interactive runs only: if stdin is a terminal, become the
// foreground process group so interactive shells can read from the
// TTY. Captured/piped runs must not: the embedding process keeps
Expand All @@ -319,19 +331,6 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
}
}

// 2. Die if parent exits
if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) } != 0 {
fail!("prctl(PR_SET_PDEATHSIG)");
}

// 3. Check parent didn't die between fork and prctl.
// Compare against the actual parent PID captured before fork rather than
// hardcoding 1, since containers often run the entrypoint as PID 1 and a
// child forked from it legitimately has getppid() == 1.
if unsafe { libc::getppid() } != parent_pid {
fail!("parent died before confinement");
}

// 4. Optional: disable ASLR
if sandbox.no_randomize_memory {
const ADDR_NO_RANDOMIZE: libc::c_ulong = 0x0040000;
Expand Down Expand Up @@ -527,6 +526,10 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
std::mem::forget(notif_fd);
}

if let Err(e) = write_u32_fd(pipes.notif_w.as_raw_fd(), exec_r as u32) {
fail!(format!("write exec pipe fd: {}", e));
}

// 10. Wait for parent to signal ready
match read_u32_fd(pipes.ready_r.as_raw_fd()) {
Ok(_) => {}
Expand All @@ -538,6 +541,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
if keep_fd >= 0 {
fds_to_keep.push(keep_fd);
}
fds_to_keep.push(exec_w);
close_fds_above(2, &fds_to_keep);

// 13. Apply environment
Expand Down Expand Up @@ -607,6 +611,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
// supervisor, so `run`'s code is already mapped; running it directly
// avoids an execve that Landlock would otherwise have to authorize.
set_proc_name(name);
unsafe { libc::close(exec_w) };
run();
unsafe { libc::_exit(0) };
}
Expand All @@ -628,6 +633,8 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
.chain(std::iter::once(std::ptr::null()))
.collect();

mark_exec(exec_w, EXEC_REACHED);

if let Some(fd) = exec_fd {
extern "C" {
static environ: *const *const libc::c_char;
Expand Down Expand Up @@ -662,8 +669,10 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
unsafe { libc::execvp(argv_ptrs[0], argv_ptrs.as_ptr()) };
}

// If we get here, exec failed
fail!(format!("exec '{}'", cmd[0].to_string_lossy()));
let exec_err = io::Error::last_os_error();
mark_exec(exec_w, EXEC_FAILED);
let _ = write!(std::io::stderr(), "sandlock child: exec '{}': {}\n", cmd[0].to_string_lossy(), exec_err);
unsafe { libc::_exit(127) };
}

// ============================================================
Expand Down
Loading
Loading