Skip to main content

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}