Skip to main content

confium_sandbox_process/
process_sandbox.rs

1//! `ProcessSandbox` โ€” the out-of-process implementation of [`Sandbox`].
2//!
3//! Each plugin runs in its own subprocess. Confium writes
4//! length-prefixed JSON-RPC [`Request`] frames to the child's stdin
5//! and reads [`Response`] frames from its stdout.
6//! A plugin that misbehaves (writes a truncated frame, returns
7//! malformed JSON, exits) is reported as an [`Error`] on the next
8//! call rather than crashing the host.
9//!
10//! Capability state is held host-side: the host refuses to forward a
11//! call whose required capability is not currently granted. This is
12//! the minimum viable gate; a future revision can additionally
13//! restrict the child at the OS level (seccomp/AppSandbox) so even a
14//! compromised plugin cannot reach the network or filesystem.
15//!
16//! See `TODO.roadmap/08-security-model.md` ยง "Track B: Out-of-process
17//! plugins".
18
19use std::collections::HashSet;
20use std::io::Read;
21use std::io::Write;
22use std::process::Child;
23use std::process::Command;
24use std::process::Stdio;
25use std::str;
26
27use snafu::Backtrace;
28use snafu::GenerateImplicitData;
29
30use crate::Error;
31use crate::Result;
32use crate::protocol::LEN_PREFIX_BYTES;
33use crate::protocol::MAX_FRAME_BYTES;
34use crate::protocol::Request;
35use crate::protocol::Response;
36use crate::protocol::parse_len;
37use crate::protocol::value_to_json;
38use crate::sandbox::Capability;
39use crate::sandbox::Sandbox;
40use crate::sandbox::SandboxInstance;
41use crate::sandbox::Value;
42
43/// The out-of-process sandbox.
44///
45/// Clone-cheap: it holds no per-instance state. Each
46/// [`load_module`](ProcessSandbox::load_module) spawns a fresh child
47/// with its own pipes and an empty capability envelope.
48#[derive(Debug, Default, Clone)]
49pub struct ProcessSandbox;
50
51impl ProcessSandbox {
52    /// Construct a new process sandbox.
53    pub fn new() -> Self {
54        Self
55    }
56}
57
58impl Sandbox for ProcessSandbox {
59    fn load_module(&self, bytes: &[u8]) -> Result<Box<dyn SandboxInstance>> {
60        let path = str::from_utf8(bytes).map_err(|e| Error::InvalidPath {
61            source: e,
62            backtrace: Backtrace::generate(),
63        })?;
64        // Trim a trailing newline that often appears when a path is
65        // read from a file or echoed in a shell. Leading/trailing
66        // whitespace is never part of a valid executable path on the
67        // platforms we support.
68        let path = path.trim();
69
70        let mut child = Command::new(path)
71            .stdin(Stdio::piped())
72            .stdout(Stdio::piped())
73            // stderr inherits so plugin diagnostics are visible during
74            // development without polluting the protocol stream.
75            .stderr(Stdio::inherit())
76            .spawn()
77            .map_err(|e| Error::Spawn {
78                source: e,
79                backtrace: Backtrace::generate(),
80            })?;
81
82        // Take the pipes now so they live on the instance; if either
83        // take fails the child is killed on drop via the guard below.
84        let stdin = child.stdin.take().ok_or_else(|| Error::Spawn {
85            source: std::io::Error::other("plugin stdin pipe was not captured"),
86            backtrace: Backtrace::generate(),
87        })?;
88        let stdout = child.stdout.take().ok_or_else(|| Error::Spawn {
89            source: std::io::Error::other("plugin stdout pipe was not captured"),
90            backtrace: Backtrace::generate(),
91        })?;
92
93        Ok(Box::new(ProcessInstance {
94            child: Some(child),
95            stdin,
96            stdout,
97            caps: CapabilitySet::new(),
98        }))
99    }
100
101    fn name(&self) -> &'static str {
102        "process"
103    }
104}
105
106/// A loaded plugin subprocess plus its capability envelope.
107///
108/// Dropping the instance kills the child (SIGKILL on Unix,
109/// TerminateProcess on Windows) so a forgotten plugin cannot outlive
110/// its host.
111pub struct ProcessInstance {
112    child: Option<Child>,
113    stdin: std::process::ChildStdin,
114    stdout: std::process::ChildStdout,
115    caps: CapabilitySet,
116}
117
118impl Drop for ProcessInstance {
119    fn drop(&mut self) {
120        // Close stdin first so a well-behaved plugin can exit on its
121        // own, then force-kill if it's still alive.
122        let _ = self.stdin.flush();
123        if let Some(child) = self.child.as_mut() {
124            let _ = child.kill();
125            let _ = child.wait();
126        }
127    }
128}
129
130impl ProcessInstance {
131    /// Send a [`Request`] frame and read back the matching [`Response`].
132    ///
133    /// Synchronous: one request in, one response out, in order. The
134    /// protocol is intentionally request/response with no pipelining
135    /// so a confused plugin cannot desynchronize the host.
136    fn round_trip(&mut self, req: &Request) -> Result<Response> {
137        let frame = req.to_frame()?;
138        self.stdin
139            .write_all(&frame)
140            .map_err(|e| Error::WriteRequest {
141                source: e,
142                backtrace: Backtrace::generate(),
143            })?;
144        self.stdin.flush().map_err(|e| Error::WriteRequest {
145            source: e,
146            backtrace: Backtrace::generate(),
147        })?;
148
149        let payload = read_frame(&mut self.stdout)?;
150        Response::from_json_bytes(&payload)
151    }
152}
153
154impl SandboxInstance for ProcessInstance {
155    fn call(&mut self, function: &str, args: &[Value]) -> Result<Vec<Value>> {
156        let json_args: Vec<_> = args.iter().map(value_to_json).collect();
157        let req = Request::new(function, json_args);
158        let resp = self.round_trip(&req)?;
159        resp.into_result(function)
160    }
161
162    fn grant_capability(&mut self, cap: Capability) -> Result<()> {
163        self.caps.grant(cap);
164        Ok(())
165    }
166
167    fn revoke_capability(&mut self, cap: &Capability) -> Result<()> {
168        self.caps.revoke(cap);
169        Ok(())
170    }
171}
172
173// ----------------------------------------------------------------------
174// Capability set โ€” host-side enforcement.
175//
176// The process sandbox enforces capability gating on the host: a call
177// is only forwarded to the subprocess if the host believes the plugin
178// is entitled. (A subprocess that has been compromised cannot be
179// trusted to enforce its own gate, but it also has no host imports to
180// call โ€” it can only respond to `call()` messages. OS-level
181// restriction of the child is a future-task item.)
182
183#[derive(Debug, Default)]
184struct CapabilitySet {
185    caps: HashSet<Capability>,
186}
187
188impl CapabilitySet {
189    fn new() -> Self {
190        Self::default()
191    }
192
193    fn grant(&mut self, cap: Capability) {
194        self.caps.insert(cap);
195    }
196
197    fn revoke(&mut self, cap: &Capability) {
198        self.caps.remove(cap);
199    }
200
201    #[allow(dead_code)]
202    fn has(&self, cap: &Capability) -> bool {
203        self.caps.contains(cap)
204    }
205}
206
207// ----------------------------------------------------------------------
208// Framed I/O helpers.
209
210/// Read one length-prefixed frame from `reader`.
211///
212/// Blocks until 4 length bytes are available, then blocks until the
213/// full payload arrives. Returns the raw JSON payload bytes (without
214/// the length prefix).
215fn read_frame<R: Read>(reader: &mut R) -> Result<Vec<u8>> {
216    let mut header = [0u8; LEN_PREFIX_BYTES];
217    read_exact_or_eof(reader, &mut header)?;
218    let len = parse_len(&header)?;
219    // Cap a single allocation; the read loop below handles short reads.
220    let mut payload = vec![0u8; len];
221    if len > 0 {
222        read_exact_or_eof(reader, &mut payload)?;
223    }
224    Ok(payload)
225}
226
227/// Like `Read::read_exact` but maps an unexpected EOF to
228/// [`Error::ReadResponse`] rather than `UnexpectedEof`. This is the
229/// right error for a pipe that closed mid-frame.
230fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<()> {
231    let mut filled = 0;
232    while filled < buf.len() {
233        let n = reader
234            .read(&mut buf[filled..])
235            .map_err(|e| Error::ReadResponse {
236                source: e,
237                backtrace: Backtrace::generate(),
238            })?;
239        if n == 0 {
240            return Err(Error::ReadResponse {
241                source: std::io::Error::new(
242                    std::io::ErrorKind::UnexpectedEof,
243                    format!(
244                        "plugin stdout closed after {filled}/{} bytes of frame",
245                        buf.len()
246                    ),
247                ),
248                backtrace: Backtrace::generate(),
249            });
250        }
251        filled += n;
252    }
253    let _ = MAX_FRAME_BYTES; // keep the constant referenced
254    Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use std::io::Cursor;
261
262    #[test]
263    fn sandbox_name_is_process() {
264        let sb = ProcessSandbox::new();
265        assert_eq!(sb.name(), "process");
266    }
267
268    #[test]
269    fn sandbox_default_works() {
270        let sb = ProcessSandbox;
271        assert_eq!(sb.name(), "process");
272    }
273
274    #[test]
275    fn read_frame_decodes_simple_payload() {
276        // length = 5, payload = "hello"
277        let mut bytes = vec![0, 0, 0, 5];
278        bytes.extend_from_slice(b"hello");
279        let mut cur = Cursor::new(bytes);
280        let payload = read_frame(&mut cur).expect("frame reads");
281        assert_eq!(&payload, b"hello");
282    }
283
284    #[test]
285    fn read_frame_empty_payload() {
286        let bytes = vec![0, 0, 0, 0];
287        let mut cur = Cursor::new(bytes);
288        let payload = read_frame(&mut cur).expect("frame reads");
289        assert!(payload.is_empty());
290    }
291
292    #[test]
293    fn read_frame_eof_on_truncated_header() {
294        let bytes = vec![0, 0];
295        let mut cur = Cursor::new(bytes);
296        let err = read_frame(&mut cur).expect_err("must fail");
297        // Truncated header surfaces as a read error (0 bytes read, then EOF).
298        assert_eq!(err.code(), 0x2103);
299    }
300
301    #[test]
302    fn read_frame_eof_on_truncated_payload() {
303        let mut bytes = vec![0, 0, 0, 10];
304        bytes.extend_from_slice(b"short");
305        let mut cur = Cursor::new(bytes);
306        let err = read_frame(&mut cur).expect_err("must fail");
307        assert_eq!(err.code(), 0x2103);
308    }
309
310    #[test]
311    fn capability_set_grant_revoke() {
312        let mut caps = CapabilitySet::new();
313        let cap = Capability::InterfaceAccess {
314            name: "hash".into(),
315        };
316        assert!(!caps.has(&cap));
317        caps.grant(cap.clone());
318        assert!(caps.has(&cap));
319        caps.revoke(&cap);
320        assert!(!caps.has(&cap));
321    }
322}