Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dd814dd
sandbox: keep background jobs supervised after main exit
congwang-mk Oct 13, 2026
9580016
ffi: separate main-process waits from session shutdown
congwang-mk Oct 13, 2026
fbcccfd
python: retain sandbox sessions after waiting for main
congwang-mk Oct 13, 2026
57a6c74
go: retain process sessions until explicit shutdown
congwang-mk Oct 13, 2026
9d20dae
docs: describe sandbox session lifetime
congwang-mk Oct 13, 2026
6c49633
sandbox: reap sessions without pidfd group signals
congwang-mk Oct 13, 2026
63fac0b
python: declare the native kill handle type
congwang-mk Oct 13, 2026
786d9f1
sandbox: track session processes without scanning procfs
congwang-mk Oct 14, 2026
53854db
pgroup: refuse group changes that cannot be tracked
congwang-mk Oct 14, 2026
3392902
reaper: outlive the thread that created the session
congwang-mk Oct 14, 2026
3a0f037
ffi: expose a pollable main-exit descriptor
congwang-mk Oct 14, 2026
9e57a01
python: wait on the main-exit descriptor outside the lock
congwang-mk Oct 14, 2026
3d1980e
go: wait on the main-exit descriptor outside the lock
congwang-mk Oct 14, 2026
4783e8c
sandbox: await the reaper instead of re-signalling on a timer
congwang-mk Oct 14, 2026
f07b7c9
control: tolerate a refused peer closing before the request is sent
congwang-mk Oct 14, 2026
6161897
python: wait natively on one-shot run handles
congwang-mk Oct 14, 2026
46898b7
ffi: report an expired wait without a timeout exit status
congwang-mk Oct 14, 2026
0d8f2cf
context: drop the parent-death check the reaper already makes
congwang-mk Oct 15, 2026
9ef6674
python: share one session object between Sandbox and Process
congwang-mk Oct 15, 2026
be74611
python: free an abandoned sandbox session without blocking
congwang-mk Oct 15, 2026
3d18f3f
pgroup: signal groups by number on kernels without pidfd group signals
congwang-mk Oct 15, 2026
b28e017
cli: kill sandboxes only through the supervisor's group table
congwang-mk Oct 15, 2026
cba0f7f
ps: stop reporting the pid of an exited main process
congwang-mk Oct 15, 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
6 changes: 4 additions & 2 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ 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) => {
Expand All @@ -647,10 +647,12 @@ pub async fn run(args: LearnArgs) -> Result<()> {
} 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
64 changes: 27 additions & 37 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,23 +285,32 @@ 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) => (
// The registry's main pid outlives main itself, so it is
// looked up only while the supervisor vouches for it. One
// that cannot answer is still alive because its socket is.
let pid = sandlock_core::control::sandbox_pids(name).map(|p| p.child);
let (status, ports, pid, uptime, cmd) = match (sandlock_core::control::sandbox_info(name), pid) {
(Ok(info), _) if info.main_exited => (
info.mode.unwrap_or_else(|| "running".to_string()),
query_ports(name),
"-".to_string(),
"-".to_string(),
"(main exited)".to_string(),
),
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()),
(Ok(info), Ok(pid)) => (
info.mode.unwrap_or_else(|| "running".to_string()),
query_ports(name),
pid.to_string(),
proc_uptime(pid).unwrap_or_else(|| "?".to_string()),
proc_cmdline(pid).unwrap_or_else(|| "?".to_string()),
),
(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 +367,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
55 changes: 55 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,61 @@ 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: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!child.wait().expect("wait for killed child").success());
}

#[test]
fn test_kill_wedged_supervisor_kills_main() {
use std::os::unix::process::ExitStatusExt;
let name = format!("test-kill-wedged-{}", std::process::id());
let mut child = spawn_sandbox(&name);
if let Err(e) = wait_for_sandbox(&mut child, &name) {
panic!("{}", e);
}
let ps = sandlock_bin().args(["ps"]).output().expect("sandlock ps");
let main: i32 = String::from_utf8_lossy(&ps.stdout)
.lines()
.find_map(|line| {
let mut fields = line.split_whitespace();
(fields.next() == Some(name.as_str())).then(|| fields.next()?.parse().ok())?
})
.expect("main pid in ps output");

unsafe { libc::kill(child.id() as i32, libc::SIGSTOP) };
let out = sandlock_bin().args(["kill", &name]).output().expect("sandlock kill");
assert!(
out.status.success(),
"kill should succeed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(child.wait().expect("wait for supervisor").signal(), Some(libc::SIGKILL));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while unsafe { libc::kill(main, 0) } == 0 {
assert!(std::time::Instant::now() < deadline, "main {main} outlived its supervisor");
std::thread::sleep(std::time::Duration::from_millis(20));
}
}

#[test]
fn test_kill_nonexistent_sandbox() {
let out = sandlock_bin()
Expand Down
18 changes: 0 additions & 18 deletions crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,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 +287,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 Down Expand Up @@ -319,19 +314,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
Loading
Loading