Skip to main content

text_mirror/runner/
jail.rs

1//! The jail contract every subprocess adapter runs under.
2//!
3//! One trait presents the contract and platform backends implement
4//! it. No jail means no run: the runner only obtains child commands
5//! from a backend, every backend routes through the sandbox helper
6//! mode, and a platform without a backend refuses the adapter run
7//! with a machine reason. An unjailed run is not a degraded mode, it
8//! is impossible by construction.
9//!
10//! Before a backend accepts adapter work, a capability probe must
11//! prove the jail from the inside: an outbound IPv4 connection, an
12//! outbound IPv6 connection, a Unix socket connection to a listener
13//! the parent holds outside the jail, and a UDP send must each
14//! return an error before the probe deadline. A success or a hang
15//! proves nothing and fails the probe. Only a passed probe is cached, keyed by the policy digest,
16//! and the cache lives in this process, which cannot outlive the
17//! boot, so a cached pass never crosses a boot or a policy change.
18
19use std::collections::HashSet;
20use std::path::Path;
21use std::process::Command;
22use std::sync::{Mutex, OnceLock};
23
24use super::protocol::bodies::{ProbeNetRequest, ProbeReport};
25use super::{Limits, Runner, RunnerError};
26use crate::hash;
27
28/// What one adapter invocation needs from a backend.
29pub struct SpawnSpec<'a> {
30    /// Absolute path of the worker binary.
31    pub worker: &'a Path,
32    /// Absolute path of the per-run jail directory.
33    pub jail: &'a Path,
34    /// The worker mode to execute after sandbox setup.
35    pub mode: &'a str,
36    /// Resource limits the helper applies before exec.
37    pub limits: &'a Limits,
38    /// Whether the syscall filter layer engages. Always true for
39    /// adapter work. The network probe disables it once, on Linux, to
40    /// observe namespace denial directly, and a second probe stage
41    /// proves the filter kills socket creation.
42    pub seccomp: bool,
43}
44
45/// A platform jail backend.
46///
47/// Implementations must never return a command that runs the worker
48/// outside the platform jail.
49pub trait JailBackend: Send + Sync {
50    /// Stable backend name for probe failures and diagnostics.
51    fn name(&self) -> &'static str;
52
53    /// Builds the jailed command for one invocation. The runner still
54    /// applies the portable contract on top: cleared environment,
55    /// working directory inside the jail, piped stdio, and a fresh
56    /// process group.
57    fn command(&self, spec: &SpawnSpec<'_>) -> Result<Command, RunnerError>;
58
59    /// A stable description of the enforced policy. It feeds the
60    /// probe cache digest, so any policy change invalidates every
61    /// cached probe pass.
62    fn policy_material(&self) -> String;
63
64    /// Proves the jail from the inside before any adapter work.
65    fn probe(&self, runner: &Runner) -> Result<(), RunnerError>;
66}
67
68/// The backend for the current platform, or a refusal on a platform
69/// without one.
70pub fn platform_backend() -> Result<Box<dyn JailBackend>, RunnerError> {
71    #[cfg(target_os = "linux")]
72    {
73        Ok(Box::new(super::linux::NamespaceJail))
74    }
75    #[cfg(target_os = "macos")]
76    {
77        Ok(Box::new(super::macos::SeatbeltJail))
78    }
79    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
80    {
81        Err(RunnerError {
82            code: "sandbox_unavailable",
83            message: "no jail backend exists for this platform, refusing to run adapters"
84                .to_string(),
85        })
86    }
87}
88
89fn probe_cache() -> &'static Mutex<HashSet<String>> {
90    static CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
91    CACHE.get_or_init(|| Mutex::new(HashSet::new()))
92}
93
94/// The digest a cached probe pass is keyed by: the backend policy,
95/// the worker path, and the worker binary hash.
96pub(super) fn policy_digest(
97    backend: &dyn JailBackend,
98    worker: &Path,
99) -> Result<String, RunnerError> {
100    let worker_hash = hash::hash_file(worker).map_err(|e| RunnerError {
101        code: "worker_not_found",
102        message: format!("cannot hash worker binary {}: {e}", worker.display()),
103    })?;
104    let material = format!(
105        "{}\n{}\n{}\n{}",
106        backend.name(),
107        backend.policy_material(),
108        worker.display(),
109        worker_hash
110    );
111    Ok(hash::hash_bytes(material.as_bytes()))
112}
113
114/// Runs the capability probe unless this policy digest already
115/// passed in this process. Only success is cached.
116pub(super) fn ensure_probed(runner: &Runner, digest: &str) -> Result<(), RunnerError> {
117    if probe_cache()
118        .lock()
119        .is_ok_and(|cache| cache.contains(digest))
120    {
121        return Ok(());
122    }
123    runner.backend().probe(runner)?;
124    if let Ok(mut cache) = probe_cache().lock() {
125        cache.insert(digest.to_string());
126    }
127    Ok(())
128}
129
130/// Whether a digest is already cached, for tests.
131pub fn probe_cached(digest: &str) -> bool {
132    probe_cache()
133        .lock()
134        .is_ok_and(|cache| cache.contains(digest))
135}
136
137/// Runs the `probe-net` worker mode inside the jail and requires
138/// every egress attempt to return an error before the probe deadline.
139///
140/// The parent holds a live Unix socket listener outside the jail for
141/// the duration. The Unix attempt would succeed from an unjailed
142/// process, so when it fails the jail provably blocked it.
143pub(super) fn probe_net(runner: &Runner, seccomp: bool) -> Result<(), RunnerError> {
144    let listener = UnixProbeListener::bind()?;
145    // Loopback TCP listeners the parent holds for the probe's
146    // duration. From an unjailed process both connects would succeed,
147    // so an immediate failure is evidence of the jail, and a timeout
148    // is scored as its own outcome and fails the probe.
149    let tcp4 = std::net::TcpListener::bind("127.0.0.1:0").map_err(probe_bind_error)?;
150    let tcp6 = std::net::TcpListener::bind("[::1]:0").map_err(probe_bind_error)?;
151    let tcp4_target = tcp4.local_addr().map_err(probe_bind_error)?.to_string();
152    let tcp6_target = tcp6.local_addr().map_err(probe_bind_error)?.to_string();
153    let payload = serde_json::to_value(ProbeNetRequest {
154        unix_target: listener.target.clone(),
155        tcp4_target,
156        tcp6_target,
157    })
158    .map_err(|e| RunnerError {
159        code: "sandbox_probe_failed",
160        message: format!("cannot encode probe request: {e}"),
161    })?;
162    let response = runner.run_unprobed("probe-net", payload, &[], seccomp)?;
163    let Some(ok) = response.ok else {
164        let detail = response
165            .error
166            .map(|e| format!("{}: {}", e.code, e.message))
167            .unwrap_or_else(|| "empty response".to_string());
168        return Err(RunnerError {
169            code: "sandbox_probe_failed",
170            message: format!("network probe reported a failure: {detail}"),
171        });
172    };
173    let report: ProbeReport = serde_json::from_value(ok).map_err(|e| RunnerError {
174        code: "sandbox_probe_failed",
175        message: format!("network probe response did not parse: {e}"),
176    })?;
177    let expected = ["ipv4_tcp", "ipv6_tcp", "unix_socket", "udp_send"];
178    for name in expected {
179        let Some(attempt) = report.attempts.iter().find(|a| a.name == name) else {
180            return Err(RunnerError {
181                code: "sandbox_probe_failed",
182                message: format!("network probe skipped the {name} attempt"),
183            });
184        };
185        if attempt.outcome != "denied" {
186            return Err(RunnerError {
187                code: "sandbox_probe_failed",
188                message: format!(
189                    "network probe attempt {name} ended {} ({}), the jail is not proven",
190                    attempt.outcome, attempt.detail
191                ),
192            });
193        }
194    }
195    Ok(())
196}
197
198/// A Unix socket listener the parent holds open outside the jail
199/// while the probe runs.
200struct UnixProbeListener {
201    target: String,
202    // Held for their drop side effects: the listener keeps the socket
203    // connectable and the directory removes a path-based socket file.
204    _listener: std::os::unix::net::UnixListener,
205    _dir: Option<tempfile::TempDir>,
206}
207
208impl UnixProbeListener {
209    #[cfg(target_os = "linux")]
210    fn bind() -> Result<UnixProbeListener, RunnerError> {
211        use std::os::linux::net::SocketAddrExt;
212        use std::sync::atomic::{AtomicU64, Ordering};
213        // The abstract name must be unique across concurrent probes in
214        // one process, so a monotonic counter joins the process id.
215        static NEXT: AtomicU64 = AtomicU64::new(0);
216        let name = format!(
217            "text-mirror-probe-{}-{}",
218            std::process::id(),
219            NEXT.fetch_add(1, Ordering::Relaxed)
220        );
221        let address = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())
222            .map_err(probe_bind_error)?;
223        let listener =
224            std::os::unix::net::UnixListener::bind_addr(&address).map_err(probe_bind_error)?;
225        Ok(UnixProbeListener {
226            target: name,
227            _listener: listener,
228            _dir: None,
229        })
230    }
231
232    #[cfg(not(target_os = "linux"))]
233    fn bind() -> Result<UnixProbeListener, RunnerError> {
234        let dir = tempfile::tempdir().map_err(probe_bind_error)?;
235        let path = dir.path().join("probe.sock");
236        let listener = std::os::unix::net::UnixListener::bind(&path).map_err(probe_bind_error)?;
237        Ok(UnixProbeListener {
238            target: path.display().to_string(),
239            _listener: listener,
240            _dir: Some(dir),
241        })
242    }
243}
244
245fn probe_bind_error(e: std::io::Error) -> RunnerError {
246    RunnerError {
247        code: "sandbox_probe_failed",
248        message: format!("cannot stand up the Unix probe listener: {e}"),
249    }
250}