lean_ctx/shell/containment/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Process containment for agent-tool calls and SDK sessions (Unix).
3//!
4//! Two rules hold for every signal sent from this module:
5//!
6//! 1. A process group is signalled by its number only while that number is
7//! pinned by an unreaped child of the signalling process that belongs to
8//! the group (POSIX: a process id is not reused while a process group with
9//! that id exists, and a group exists until its last member — a zombie
10//! included — is gone).
11//! 2. The signalling process has verified that it is not a member of the
12//! group. If that cannot be shown, nothing is signalled.
13//!
14//! The parts:
15//!
16//! - [`CallGuard`]: one guard process per agent-tool call. It joins the call's
17//! group, leaves a pin behind, moves to a group of its own and waits for its
18//! input to end — the call finished, or the Engine is gone. Then it ends the
19//! call's group, repeatedly, until no live member is left.
20//! - the session starter (`starter`): the process an SDK starts. It runs the
21//! Engine in a new group, stays outside, and ends that group when the Engine
22//! exits, when it is asked to, or when its parent is gone. A guard of the
23//! first kind covers the starter's own death, and the Engine's program is
24//! not executed before that guard is ready (`gate`).
25//! - the spawn helper (`spawn_helper`, `spawner`): the MCP shell
26//! (`server/execute.rs`) leads a session of its own (`setsid`), and a guard
27//! started outside that session cannot join its group. One long-lived
28//! helper per Engine process is the parent of those shells instead; it ends
29//! what it still holds when the Engine is gone without a word.
30//!
31//! Windows is not covered here; nothing in this module is built there. Its
32//! containment is in `shell::process_tree`: a kill-on-close Job Object per
33//! MCP shell call, and for an SDK session the Engine's own kill-on-close job
34//! (`contain_requested_session`), which answers the same request variable
35//! with the same acknowledgement.
36
37use std::time::{Duration, Instant};
38
39/// Whether helpers and guards start as this test harness's ignored helper
40/// test (`tests::HELPER_TEST`) instead of the hidden modes of the `lean-ctx`
41/// binary. Always in the Engine's unit tests; under `test-support` only in the
42/// server crate's unit-test harness, which mounts that test at the same path —
43/// never in the `lean-ctx` binary that integration tests spawn.
44#[cfg(any(test, feature = "test-support"))]
45fn harness_helper() -> bool {
46 #[cfg(test)]
47 {
48 true
49 }
50 #[cfg(not(test))]
51 {
52 static HARNESS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
53 *HARNESS.get_or_init(|| {
54 std::env::current_exe()
55 .ok()
56 .and_then(|exe| exe.file_name().map(|n| n.to_string_lossy().into_owned()))
57 .is_some_and(|name| name.starts_with("lean_ctx_server-"))
58 })
59 }
60}
61
62mod gate;
63mod guard;
64mod spawn_helper;
65mod spawner;
66mod starter;
67#[cfg(any(test, feature = "test-support"))]
68#[cfg_attr(not(test), allow(dead_code, unused_imports, clippy::unwrap_used))]
69pub mod tests;
70mod wire;
71
72pub use gate::{Refusal, start_behind_guard};
73#[cfg(any(test, feature = "test-support"))]
74pub use guard::force_outcome_for_test;
75pub use guard::{CallGuard, GuardOutcome};
76pub(crate) use spawn_helper::lead_new_session;
77pub use spawner::{HelpedChild, HelperSpawn, shutdown_spawn_helper, spawn_shell};
78
79/// Whether this process is the `lean-ctx` binary, which is what can be
80/// started as a guard. Set by [`dispatch_hidden_mode`], the first thing the
81/// binary's entry point calls; true in this crate's own unit tests, whose
82/// binary carries a helper that runs the same guard.
83static GUARD_HOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(cfg!(test));
84
85/// [`GUARD_HOST`], or the server crate's unit-test harness, whose binary
86/// carries the same helper test (`harness_helper`).
87fn guard_host() -> bool {
88 #[cfg(any(test, feature = "test-support"))]
89 if harness_helper() {
90 return true;
91 }
92 GUARD_HOST.load(std::sync::atomic::Ordering::Relaxed)
93}
94
95/// Starts an Engine child that is not an agent-tool call (bounded captures,
96/// the sandbox runner, the credential reader) behind its guard: the program
97/// is executed only once the guard is ready ([`start_behind_guard`]).
98/// `command` must ask for a process group of its own and is not to be
99/// spawned again.
100///
101/// One addition to that contract: in a process that is not the `lean-ctx`
102/// binary — this crate linked into another executable — there is nothing
103/// that could be started as a guard. Such a child is then started exactly as
104/// it was before guards existed (`None` for the guard). An agent-tool call
105/// does not get this exception; it uses [`start_behind_guard`] itself.
106///
107/// Limit, stated plainly: library embedders and every executable other than
108/// `lean-ctx` get NO call guard at these sites. Their children are ended by
109/// the sites' own timeout and limit paths only; if such a host process dies,
110/// nothing ends what it had started. This is logged once per process at debug
111/// level.
112pub(crate) fn start_guarded_child(
113 command: &mut std::process::Command,
114) -> Result<(std::process::Child, Option<CallGuard>), Refusal> {
115 if !guard_host() {
116 note_no_guard_host();
117 return command
118 .spawn()
119 .map(|child| (child, None))
120 .map_err(Refusal::NotStarted);
121 }
122 start_behind_guard(command).map(|(child, guard)| (child, Some(guard)))
123}
124
125/// Says once per process, at debug level, that this executable cannot start
126/// guards or a spawn helper.
127fn note_no_guard_host() {
128 static NOTED: std::sync::Once = std::sync::Once::new();
129 NOTED.call_once(|| {
130 tracing::debug!(
131 "process guard unavailable: this executable is not lean-ctx; child processes run without a guard"
132 );
133 });
134}
135
136/// Hidden mode: the guard of one process group. `__group-guard --group <pgid>`.
137pub(crate) const GUARD_MODE: &str = "__group-guard";
138/// Hidden mode: the session starter.
139/// `__session-starter [--ack] [--watch-parent] [--control-fd <n>] -- <program> [args…]`.
140pub(crate) const STARTER_MODE: &str = "__session-starter";
141/// Hidden mode: the spawn helper of one Engine process. `__spawn-helper`, no
142/// arguments; standard input is the Engine's socket.
143pub(crate) const HELPER_MODE: &str = "__spawn-helper";
144/// Set to `1` by an SDK on an `engine …` start: the binary then runs as the
145/// session starter of that same command line.
146pub(crate) const SESSION_ENV: &str = "LEAN_CTX_SESSION_CONTAINMENT";
147/// The starter's acknowledgement: the first line on standard output, before
148/// the Engine is started. An Engine without this mode never writes it.
149pub(crate) const SESSION_ACK: &[u8] = b"{\"leanctx_session_containment\":1}\n";
150
151/// The command line is not one of the hidden modes' forms.
152pub(crate) const EXIT_USAGE: i32 = 64;
153/// The topology could not be verified or the group could not be ended.
154pub(crate) const EXIT_CONTAINMENT: i32 = 70;
155
156/// What a caller is told when a call's process group could not be confirmed
157/// as ended. Fixed text: no process id, no time, nothing of the run (#498).
158pub const END_NOTICE: &str =
159 "[lean-ctx: processes started by this command could not be confirmed as ended]";
160
161/// What a caller is told when an MCP shell command ran without the spawn
162/// helper: nothing would have ended it had lean-ctx died meanwhile. Fixed
163/// text, as above.
164pub const HELPER_NOTICE: &str = "[lean-ctx: this command ran without the process helper; had lean-ctx stopped, nothing would have ended it]";
165
166/// What a caller is told when the spawn helper went away without saying
167/// whether it had started the command. Nothing seen afterwards can prove
168/// that a command did not run, so it is never started a second time. Fixed
169/// text, as above.
170pub const UNCLEAR_NOTICE: &str =
171 "[lean-ctx: this command may or may not have run; it was not repeated]";
172
173/// What a caller is told when a command finished and the word that lets what
174/// it left running go on could not be handed to the spawn helper: a helper
175/// that never read it ends those processes. Fixed text, as above.
176pub const RELEASE_NOTICE: &str = "[lean-ctx: this command finished, but lean-ctx could not confirm that its background processes were left running]";
177
178/// The notice for a guard outcome; `None` when the group is known to be empty.
179pub fn end_notice(outcome: GuardOutcome) -> Option<&'static str> {
180 (outcome != GuardOutcome::Ended).then_some(END_NOTICE)
181}
182
183/// How long a group may take to become empty once it is being ended.
184const END_BUDGET: Duration = Duration::from_secs(5);
185
186/// Runs the hidden mode this invocation asks for, if any, and returns its exit
187/// code. Called before any other start-up work: no configuration, no data
188/// directory, no logging, no telemetry.
189pub fn dispatch_hidden_mode() -> Option<i32> {
190 GUARD_HOST.store(true, std::sync::atomic::Ordering::Relaxed);
191 let mut args = std::env::args_os();
192 let _program = args.next();
193 let first = args.next()?;
194 if first == GUARD_MODE {
195 return Some(guard::main(args));
196 }
197 if first == STARTER_MODE {
198 return Some(starter::main_explicit(args));
199 }
200 if first == HELPER_MODE {
201 return Some(spawn_helper::main(args));
202 }
203 if first == "engine" && std::env::var_os(SESSION_ENV).is_some_and(|value| value == "1") {
204 // `None`: this binary cannot name itself. It then runs as a plain
205 // Engine and writes no acknowledgement, which the SDK reports.
206 return starter::main_requested();
207 }
208 None
209}
210
211/// What became of a group that was to be ended.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub(crate) enum GroupEnd {
214 /// No live member was left, seen twice in a row after the last signal.
215 Empty,
216 /// The signals were sent; the process table could not be read.
217 Unconfirmed,
218 /// Live members remained when the budget was spent.
219 Survivors,
220 /// Nothing was signalled: the caller is a member of the group, or the
221 /// number is not a group this process could own.
222 Refused,
223}
224
225/// Ends the group `group` with SIGKILL, again and again until no live member
226/// is left — a member that forks while the group is signalled can leave a
227/// child the signal did not reach.
228///
229/// Caller contract (rule 1 above): for the whole call, an unreaped child of
230/// this process is a member of `group`. Rule 2 is checked here.
231pub(crate) fn end_pinned_group(group: libc::pid_t, budget: Duration) -> GroupEnd {
232 const ROUNDS_WITHOUT_SIGHT: u32 = 5;
233 // 0/negative/1, this process, or the group this process is in: refused
234 // before any signal (`group_target` checks getpgrp).
235 let Some(group) = u32::try_from(group)
236 .ok()
237 .and_then(|group| crate::ipc::process::group_target(group).ok())
238 else {
239 return GroupEnd::Refused;
240 };
241 let deadline = Instant::now() + budget;
242 let mut rounds = 0_u32;
243 loop {
244 // SAFETY: plain syscall; the group id is pinned by the caller's
245 // unreaped child and this process is not a member. The result is not
246 // used: an empty group answers ESRCH, Darwin answers EPERM for a group
247 // of zombies only.
248 unsafe { libc::killpg(group, libc::SIGKILL) };
249 rounds += 1;
250 let pause = Duration::from_millis(u64::from(rounds.min(10)) * 2);
251 // The next signal is sent only after a live member was seen, or —
252 // without sight of the process table — for a few rounds on the pin
253 // alone. Once the group is seen empty, nothing more is sent.
254 std::thread::sleep(pause);
255 let seen = live_members(group);
256 if seen == Some(0) {
257 std::thread::sleep(pause);
258 if live_members(group) == Some(0) {
259 return GroupEnd::Empty;
260 }
261 } else if seen.is_none() && rounds >= ROUNDS_WITHOUT_SIGHT {
262 return GroupEnd::Unconfirmed;
263 }
264 if Instant::now() >= deadline {
265 return GroupEnd::Survivors;
266 }
267 }
268}
269
270/// How many live (not zombie) processes are members of `group`, read from the
271/// process table. `None` when it cannot be read. Sends no signal.
272pub fn live_members(group: libc::pid_t) -> Option<usize> {
273 let ps = ["/bin/ps", "/usr/bin/ps"]
274 .into_iter()
275 .find(|path| std::path::Path::new(path).is_file())
276 .unwrap_or("ps");
277 let output = std::process::Command::new(ps)
278 .args(["-A", "-o", "pgid=", "-o", "stat="])
279 .stdin(std::process::Stdio::null())
280 .stderr(std::process::Stdio::null())
281 .output()
282 .ok()?;
283 if !output.status.success() {
284 return None;
285 }
286 Some(count_live(&String::from_utf8_lossy(&output.stdout), group))
287}
288
289/// Counts the rows `<pgid> <state>` of `group` whose state is not a zombie's.
290fn count_live(table: &str, group: libc::pid_t) -> usize {
291 table
292 .lines()
293 .filter(|line| {
294 let mut fields = line.split_whitespace();
295 let in_group = fields
296 .next()
297 .and_then(|pgid| pgid.parse::<libc::pid_t>().ok())
298 == Some(group);
299 in_group && !fields.next().is_some_and(|state| state.starts_with('Z'))
300 })
301 .count()
302}
303
304/// Writes all of `bytes` to the descriptor, without any buffering of ours.
305fn write_fd(fd: libc::c_int, mut bytes: &[u8]) -> bool {
306 while !bytes.is_empty() {
307 // SAFETY: the pointer and length describe `bytes`.
308 let written = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
309 if written > 0 {
310 bytes = &bytes[written as usize..];
311 } else if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
312 return false;
313 }
314 }
315 true
316}
317
318/// The signals a host may send to "everything"; `handler` is `SIG_IGN` or a
319/// handler function.
320fn set_termination_signals(handler: libc::sighandler_t) {
321 for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
322 // SAFETY: installs a disposition; the handler, where it is one, only
323 // stores to an atomic.
324 unsafe { libc::signal(signal, handler) };
325 }
326 // SAFETY: a closed peer must surface as a write error, not end us.
327 unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) };
328}