Skip to main content

text_mirror/runner/
mod.rs

1//! The sandboxed subprocess runner behind every subprocess adapter.
2//!
3//! The runner enforces the whole sandbox contract from the design
4//! document: a wall-clock timeout, separate stdout and stderr caps
5//! drained concurrently, no network, no inherited environment, and a
6//! temp-dir working jail. Every violation fails closed with a
7//! machine-readable reason and no partial result.
8//!
9//! The spawn is two-stage. The parent builds a `std::process::Command`
10//! through the platform [`jail::JailBackend`] with a cleared
11//! environment, the jail as working directory, piped stdio, and a
12//! fresh process group. The child starts in the worker binary's
13//! single-threaded sandbox helper mode, which applies namespaces,
14//! resource limits, descriptor hygiene, the filesystem jail, and the
15//! syscall filter, then execs the requested adapter mode. No sandbox
16//! work runs in a `pre_exec` closure.
17//!
18//! The wall clock is a parent-owned monotonic deadline. When it fires,
19//! or when either output cap is crossed, the parent kills the whole
20//! process group with SIGKILL. The same group kill also runs after a
21//! normal clean exit, so no descendant outlives the invocation, and
22//! the post-exit drain join is deadline-bounded, so the parent never
23//! blocks on a pipe an escaped descendant holds open. A response
24//! frame is accepted only from a child that wrote exactly one
25//! complete frame, nothing after it, and exited cleanly.
26
27use std::io::Read;
28use std::path::PathBuf;
29use std::process::{Child, Stdio};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::time::{Duration, Instant};
33
34pub mod helper;
35pub mod jail;
36pub mod protocol;
37pub mod worker;
38
39#[cfg(target_os = "linux")]
40pub mod linux;
41#[cfg(target_os = "macos")]
42pub mod macos;
43
44use jail::{JailBackend, SpawnSpec};
45use protocol::{FrameRead, Request, RequestSchema, Response};
46
47/// Resource and output limits for one adapter class.
48#[derive(Debug, Clone)]
49pub struct Limits {
50    /// Wall-clock ceiling enforced by the parent.
51    pub wall_timeout: Duration,
52    /// Ceiling on the response frame payload, checked against the
53    /// declared length before allocation.
54    pub max_response_bytes: u32,
55    /// Ceiling on stderr bytes, drained and counted concurrently.
56    pub max_stderr_bytes: u64,
57    /// RLIMIT_AS for the child, in bytes.
58    pub address_space_bytes: u64,
59    /// RLIMIT_CPU for the child, in seconds.
60    pub cpu_seconds: u64,
61    /// RLIMIT_FSIZE for the child, in bytes. Caps regular-file growth
62    /// inside the jail. Stdout is a pipe, so the response cap above
63    /// is enforced by the parent instead.
64    pub file_size_bytes: u64,
65    /// RLIMIT_NPROC for the child. An absolute cap on the process and
66    /// thread count of the mapped real user id, so a compromised
67    /// adapter cannot fork-bomb the host. The default is a modest
68    /// absolute value: the shipped adapter spawns nothing, so on a
69    /// host where the user already runs more tasks than the cap,
70    /// every fork from the adapter fails, which is the intent.
71    pub max_processes: u64,
72}
73
74impl Default for Limits {
75    fn default() -> Limits {
76        Limits {
77            wall_timeout: Duration::from_secs(120),
78            max_response_bytes: 192 * 1024 * 1024,
79            max_stderr_bytes: 1024 * 1024,
80            address_space_bytes: 4 * 1024 * 1024 * 1024,
81            cpu_seconds: 300,
82            file_size_bytes: 512 * 1024 * 1024,
83            max_processes: 16,
84        }
85    }
86}
87
88/// A runner failure with a machine-readable reason.
89///
90/// The codes are stable: `sandbox_unavailable`, `sandbox_probe_failed`,
91/// `worker_not_found`, `adapter_spawn_error`, `adapter_timeout`,
92/// `adapter_frame_oversized`, `adapter_output_overflow`,
93/// `adapter_protocol_error`, `adapter_drain_timeout`, and
94/// `adapter_crash`.
95#[derive(Debug, Clone)]
96pub struct RunnerError {
97    /// Stable reason code.
98    pub code: &'static str,
99    /// Detail for a human reading the manifest.
100    pub message: String,
101}
102
103impl std::fmt::Display for RunnerError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(f, "{}: {}", self.code, self.message)
106    }
107}
108
109impl std::error::Error for RunnerError {}
110
111/// File name of the worker binary this crate ships.
112pub const WORKER_BINARY: &str = "text-mirror-worker";
113
114/// Exit code the sandbox helper uses when jail setup fails, so the
115/// parent can tell a refused jail from an adapter crash.
116pub const SANDBOX_SETUP_EXIT: i32 = 71;
117
118/// Locates the worker binary beside the current executable, or one
119/// directory up, which covers an installed layout and a build tree.
120pub fn locate_worker() -> Result<PathBuf, RunnerError> {
121    let missing = |detail: String| RunnerError {
122        code: "worker_not_found",
123        message: detail,
124    };
125    let current = std::env::current_exe()
126        .map_err(|e| missing(format!("cannot resolve the current executable: {e}")))?;
127    let Some(dir) = current.parent() else {
128        return Err(missing(
129            "the current executable has no directory".to_string(),
130        ));
131    };
132    let mut candidates = vec![dir.join(WORKER_BINARY)];
133    if let Some(parent) = dir.parent() {
134        candidates.push(parent.join(WORKER_BINARY));
135    }
136    for candidate in &candidates {
137        if candidate.is_file() {
138            return candidate
139                .canonicalize()
140                .map_err(|e| missing(format!("cannot canonicalize {}: {e}", candidate.display())));
141        }
142    }
143    Err(missing(format!(
144        "no {WORKER_BINARY} beside {}",
145        current.display()
146    )))
147}
148
149/// Runs worker modes under the platform jail.
150pub struct Runner {
151    backend: Box<dyn JailBackend>,
152    worker: PathBuf,
153    limits: Limits,
154}
155
156impl Runner {
157    /// A runner over an explicit backend, worker, and limits.
158    pub fn new(backend: Box<dyn JailBackend>, worker: PathBuf, limits: Limits) -> Runner {
159        Runner {
160            backend,
161            worker,
162            limits,
163        }
164    }
165
166    /// The platform backend, the co-located worker binary, and
167    /// default limits.
168    pub fn with_platform_defaults() -> Result<Runner, RunnerError> {
169        Ok(Runner::new(
170            jail::platform_backend()?,
171            locate_worker()?,
172            Limits::default(),
173        ))
174    }
175
176    /// The backend this runner spawns through.
177    pub fn backend(&self) -> &dyn JailBackend {
178        self.backend.as_ref()
179    }
180
181    /// The limits this runner enforces.
182    pub fn limits(&self) -> &Limits {
183        &self.limits
184    }
185
186    /// The policy digest that keys this runner's probe cache entry.
187    pub fn policy_digest(&self) -> Result<String, RunnerError> {
188        jail::policy_digest(self.backend.as_ref(), &self.worker)
189    }
190
191    /// Runs one adapter mode inside the jail and returns its response.
192    ///
193    /// `files` are written into a fresh jail directory before the
194    /// spawn, so the child reads inputs by bare name from its working
195    /// directory. The capability probe must have passed for the
196    /// current policy digest, and runs once per process if not.
197    pub fn run(
198        &self,
199        mode: &str,
200        payload: serde_json::Value,
201        files: &[(&str, &[u8])],
202    ) -> Result<Response, RunnerError> {
203        let digest = self.policy_digest()?;
204        jail::ensure_probed(self, &digest)?;
205        self.run_unprobed(mode, payload, files, true)
206    }
207
208    /// Runs one mode without the probe gate. Only the probe itself
209    /// uses this, and only through the jail backend.
210    pub(crate) fn run_unprobed(
211        &self,
212        mode: &str,
213        payload: serde_json::Value,
214        files: &[(&str, &[u8])],
215        seccomp: bool,
216    ) -> Result<Response, RunnerError> {
217        let jail_dir = tempfile::tempdir().map_err(|e| RunnerError {
218            code: "adapter_spawn_error",
219            message: format!("cannot create the jail directory: {e}"),
220        })?;
221        let jail_path = jail_dir.path().canonicalize().map_err(|e| RunnerError {
222            code: "adapter_spawn_error",
223            message: format!("cannot canonicalize the jail directory: {e}"),
224        })?;
225        for (name, bytes) in files {
226            if name.contains('/') || name.contains("..") {
227                return Err(RunnerError {
228                    code: "adapter_spawn_error",
229                    message: format!("input name {name:?} is not a bare file name"),
230                });
231            }
232            std::fs::write(jail_path.join(name), bytes).map_err(|e| RunnerError {
233                code: "adapter_spawn_error",
234                message: format!("cannot stage input {name:?}: {e}"),
235            })?;
236        }
237
238        let spec = SpawnSpec {
239            worker: &self.worker,
240            jail: &jail_path,
241            mode,
242            limits: &self.limits,
243            seccomp,
244        };
245        let mut command = self.backend.command(&spec)?;
246        command
247            .env_clear()
248            .current_dir(&jail_path)
249            .stdin(Stdio::piped())
250            .stdout(Stdio::piped())
251            .stderr(Stdio::piped());
252        #[cfg(unix)]
253        {
254            use std::os::unix::process::CommandExt;
255            command.process_group(0);
256        }
257        let mut child = command.spawn().map_err(|e| RunnerError {
258            code: "adapter_spawn_error",
259            message: format!("cannot spawn the jailed worker: {e}"),
260        })?;
261
262        let request = Request {
263            schema: RequestSchema,
264            adapter: mode.to_string(),
265            payload,
266        };
267        if let Some(mut stdin) = child.stdin.take() {
268            // A child that dies before reading breaks the pipe. That
269            // is not a verdict, the exit status below is.
270            let _ = protocol::write_frame(&mut stdin, &request);
271        }
272
273        drive(&mut child, &self.limits)
274    }
275}
276
277/// What the stdout drain thread observed.
278enum StdoutEnd {
279    Frame(Vec<u8>),
280    Empty,
281    Truncated,
282    Oversized { declared: u64 },
283    Trailing,
284    ReadError(String),
285}
286
287/// How long the parent waits for the drains to reach EOF after the
288/// final group kill. A drain still open past this bound means a
289/// descendant escaped the process group and holds a pipe, and the
290/// parent detaches the drain and fails closed instead of hanging.
291const DRAIN_JOIN_BOUND: Duration = Duration::from_secs(2);
292
293/// Drains both pipes concurrently, enforces the deadline and caps,
294/// and interprets the exit.
295///
296/// The group-lifetime contract: no descendant outlives the
297/// invocation. The parent SIGKILLs the whole process group on every
298/// exit path, the normal clean exit included, before it returns. The
299/// parent itself never blocks without a bound: the post-exit drain
300/// join is deadline-bounded, and a drain that does not finish is
301/// detached while the call returns a fail-closed reason.
302fn drive(child: &mut Child, limits: &Limits) -> Result<Response, RunnerError> {
303    let kill_now = Arc::new(AtomicBool::new(false));
304
305    let stdout = child.stdout.take().expect("stdout was piped");
306    let stdout_kill = kill_now.clone();
307    let max_response = limits.max_response_bytes;
308    let (stdout_done, stdout_result) = std::sync::mpsc::channel();
309    std::thread::spawn(move || {
310        let mut reader = stdout;
311        let end = match protocol::read_frame(&mut reader, max_response) {
312            Ok(FrameRead::Complete(payload)) => {
313                // One frame is the whole grammar. Any byte after it is
314                // a protocol violation and kills the child.
315                let mut probe = [0u8; 1];
316                match reader.read(&mut probe) {
317                    Ok(0) => StdoutEnd::Frame(payload),
318                    Ok(_) => StdoutEnd::Trailing,
319                    Err(e) => StdoutEnd::ReadError(e.to_string()),
320                }
321            }
322            Ok(FrameRead::Empty) => StdoutEnd::Empty,
323            Ok(FrameRead::Truncated) => StdoutEnd::Truncated,
324            Ok(FrameRead::Oversized { declared }) => StdoutEnd::Oversized { declared },
325            Err(e) => StdoutEnd::ReadError(e.to_string()),
326        };
327        if matches!(
328            end,
329            StdoutEnd::Oversized { .. } | StdoutEnd::Trailing | StdoutEnd::ReadError(_)
330        ) {
331            stdout_kill.store(true, Ordering::SeqCst);
332        }
333        if matches!(end, StdoutEnd::Oversized { .. } | StdoutEnd::Trailing) {
334            // Keep the pipe drained so the child cannot stall on a
335            // full pipe between the violation and the kill. The bytes
336            // are discarded.
337            let mut sink = [0u8; 8192];
338            while matches!(reader.read(&mut sink), Ok(n) if n > 0) {}
339        }
340        let _ = stdout_done.send(end);
341    });
342
343    let stderr = child.stderr.take().expect("stderr was piped");
344    let stderr_kill = kill_now.clone();
345    let max_stderr = limits.max_stderr_bytes;
346    let (stderr_done, stderr_result) = std::sync::mpsc::channel();
347    std::thread::spawn(move || {
348        let mut reader = stderr;
349        let mut kept = Vec::new();
350        let mut total: u64 = 0;
351        let mut chunk = [0u8; 8192];
352        loop {
353            match reader.read(&mut chunk) {
354                Ok(0) => break,
355                Ok(n) => {
356                    total += n as u64;
357                    if kept.len() < 4096 {
358                        let take = (4096 - kept.len()).min(n);
359                        kept.extend_from_slice(&chunk[..take]);
360                    }
361                    if total > max_stderr {
362                        stderr_kill.store(true, Ordering::SeqCst);
363                        // Keep draining so the child cannot stall.
364                    }
365                }
366                Err(_) => break,
367            }
368        }
369        let _ = stderr_done.send((kept, total));
370    });
371
372    let deadline = Instant::now() + limits.wall_timeout;
373    let mut timed_out = false;
374    let status = loop {
375        if let Some(status) = child.try_wait().ok().flatten() {
376            break status;
377        }
378        if Instant::now() >= deadline {
379            timed_out = true;
380            kill_group(child);
381            break wait_after_kill(child);
382        }
383        if kill_now.load(Ordering::SeqCst) {
384            kill_group(child);
385            break wait_after_kill(child);
386        }
387        std::thread::sleep(Duration::from_millis(2));
388    };
389
390    // The final group kill runs on every exit path, the normal clean
391    // exit included, so a descendant that closed its stdio and
392    // lingered past its leader is still killed. The group id stays
393    // reserved while any member lives, and ESRCH on an empty group is
394    // fine.
395    kill_group(child);
396
397    // Bounded drain join. On Linux the group kill guarantees EOF,
398    // because a descendant cannot leave the group. On macOS a
399    // descendant that changed its session can survive the group kill
400    // holding a pipe, and this bound is what keeps the parent from
401    // hanging on it: the drain is detached and the call fails closed.
402    let join_deadline = Instant::now() + DRAIN_JOIN_BOUND;
403    let stdout_end = stdout_result.recv_timeout(DRAIN_JOIN_BOUND).ok();
404    let stderr_end = stderr_result
405        .recv_timeout(join_deadline.saturating_duration_since(Instant::now()))
406        .ok();
407    let (Some(stdout_end), Some((stderr_kept, stderr_total))) = (stdout_end, stderr_end) else {
408        return Err(RunnerError {
409            code: "adapter_drain_timeout",
410            message: format!(
411                "a pipe stayed open {DRAIN_JOIN_BOUND:?} after the group kill, so a descendant escaped the process group; the drain is detached and all output is discarded"
412            ),
413        });
414    };
415
416    if timed_out {
417        return Err(RunnerError {
418            code: "adapter_timeout",
419            message: format!(
420                "killed the process group after the {:?} wall-clock ceiling, partial output discarded",
421                limits.wall_timeout
422            ),
423        });
424    }
425    if let StdoutEnd::Oversized { declared } = stdout_end {
426        return Err(RunnerError {
427            code: "adapter_frame_oversized",
428            message: format!(
429                "response frame declared {declared} bytes over the {} byte ceiling, refused before allocation",
430                limits.max_response_bytes
431            ),
432        });
433    }
434    if stderr_total > limits.max_stderr_bytes {
435        return Err(RunnerError {
436            code: "adapter_output_overflow",
437            message: format!(
438                "stderr wrote {stderr_total} bytes over the {} byte cap",
439                limits.max_stderr_bytes
440            ),
441        });
442    }
443    if matches!(stdout_end, StdoutEnd::Trailing) {
444        return Err(RunnerError {
445            code: "adapter_output_overflow",
446            message: "bytes after the response frame, one frame is the whole stdout budget"
447                .to_string(),
448        });
449    }
450    if !status.success() {
451        let detail = exit_detail(&status);
452        let stderr_text = String::from_utf8_lossy(&stderr_kept);
453        let stderr_text = stderr_text.trim();
454        #[cfg(unix)]
455        let setup_failed = status.code() == Some(SANDBOX_SETUP_EXIT);
456        #[cfg(not(unix))]
457        let setup_failed = false;
458        if setup_failed {
459            return Err(RunnerError {
460                code: "sandbox_unavailable",
461                message: format!("the sandbox helper refused to engage the jail: {stderr_text}"),
462            });
463        }
464        return Err(RunnerError {
465            code: "adapter_crash",
466            message: if stderr_text.is_empty() {
467                format!("worker {detail}, any response frame is discarded")
468            } else {
469                format!("worker {detail}: {stderr_text}")
470            },
471        });
472    }
473    let payload = match stdout_end {
474        StdoutEnd::Frame(payload) => payload,
475        StdoutEnd::Empty => {
476            return Err(RunnerError {
477                code: "adapter_protocol_error",
478                message: "worker exited without a response frame".to_string(),
479            });
480        }
481        StdoutEnd::Truncated => {
482            return Err(RunnerError {
483                code: "adapter_protocol_error",
484                message: "worker exited with a truncated response frame".to_string(),
485            });
486        }
487        StdoutEnd::ReadError(detail) => {
488            return Err(RunnerError {
489                code: "adapter_protocol_error",
490                message: format!("reading the response failed: {detail}"),
491            });
492        }
493        StdoutEnd::Oversized { .. } | StdoutEnd::Trailing => unreachable!("handled above"),
494    };
495    let response: Response = serde_json::from_slice(&payload).map_err(|e| RunnerError {
496        code: "adapter_protocol_error",
497        message: format!("response frame did not parse: {e}"),
498    })?;
499    response.validate().map_err(|detail| RunnerError {
500        code: "adapter_protocol_error",
501        message: detail,
502    })?;
503    Ok(response)
504}
505
506/// Kills the child's whole process group with SIGKILL.
507fn kill_group(child: &mut Child) {
508    #[cfg(unix)]
509    {
510        if let Ok(pid) = i32::try_from(child.id()) {
511            // The child is its own group leader via process_group(0),
512            // and ESRCH after the group is gone is fine.
513            let _ = nix::sys::signal::killpg(
514                nix::unistd::Pid::from_raw(pid),
515                nix::sys::signal::Signal::SIGKILL,
516            );
517        }
518    }
519    #[cfg(not(unix))]
520    {
521        let _ = child.kill();
522    }
523}
524
525fn wait_after_kill(child: &mut Child) -> std::process::ExitStatus {
526    child.wait().unwrap_or_else(|_| {
527        // The child was already reaped by try_wait.
528        child
529            .try_wait()
530            .ok()
531            .flatten()
532            .expect("killed child has an exit status")
533    })
534}
535
536fn exit_detail(status: &std::process::ExitStatus) -> String {
537    #[cfg(unix)]
538    {
539        use std::os::unix::process::ExitStatusExt;
540        if let Some(signal) = status.signal() {
541            return format!("died on signal {signal}");
542        }
543    }
544    match status.code() {
545        Some(code) => format!("exited with code {code}"),
546        None => "exited without a status".to_string(),
547    }
548}