Skip to main content

consortium_cli/
sandbox.rs

1//! Isolation policy for running untrusted commands on fleet nodes.
2//!
3//! [`SandboxSpec`] is deliberately *data*, not behaviour: a side-effect-free
4//! description of what a command may touch. It serializes to JSON so a policy
5//! is reviewable in a pull request and testable with no hypervisor, no
6//! `/dev/kvm`, and no VM to boot.
7//!
8//! [`Sandbox`] is the execution seam. A backend turns a spec plus a command
9//! into an outcome; [`Sandbox::is_isolated`] reports honestly whether the
10//! backend actually enforced anything, so a degraded run is visible rather
11//! than silently unprotected.
12//!
13//! No hypervisor backend is wired up yet. The libkrun one cannot be built or
14//! tested on this machine - libkrun is Linux-only in nixpkgs - so it lives
15//! behind a feature gate rather than shipping unexercised FFI.
16
17use std::io;
18use std::path::PathBuf;
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23/// Whether a sandboxed command may reach the network.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "kebab-case")]
26pub enum NetworkPolicy {
27    /// No network access from inside the sandbox.
28    #[default]
29    Deny,
30    /// The command may open outbound connections.
31    Allow,
32}
33
34/// What a sandboxed command is permitted to touch.
35///
36/// Construct with [`SandboxSpec::deny_all`], then relax. Validation lives in
37/// [`SandboxSpec::validate`] so an impossible policy is rejected before any
38/// process is spawned.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[serde(rename_all = "kebab-case")]
41pub struct SandboxSpec {
42    /// Paths mounted read-only inside the sandbox.
43    pub read_only_paths: Vec<PathBuf>,
44    /// Paths the command may write.
45    pub writable_paths: Vec<PathBuf>,
46    /// Network reachability.
47    pub network: NetworkPolicy,
48    /// Environment handed to the command, applied after the backend default.
49    pub env: Vec<(String, String)>,
50    /// Wall-clock limit; `None` means the backend default applies.
51    pub timeout: Option<Duration>,
52}
53
54impl SandboxSpec {
55    /// A policy that grants nothing: no writable paths, no network.
56    pub fn deny_all() -> Self {
57        Self::default()
58    }
59
60    /// Grant a read-only path.
61    #[must_use]
62    pub fn with_read_only(mut self, path: impl Into<PathBuf>) -> Self {
63        self.read_only_paths.push(path.into());
64        self
65    }
66
67    /// Grant a writable path.
68    #[must_use]
69    pub fn with_writable(mut self, path: impl Into<PathBuf>) -> Self {
70        self.writable_paths.push(path.into());
71        self
72    }
73
74    /// Set network reachability.
75    #[must_use]
76    pub fn with_network(mut self, network: NetworkPolicy) -> Self {
77        self.network = network;
78        self
79    }
80
81    /// Set a wall-clock limit.
82    #[must_use]
83    pub fn with_timeout(mut self, timeout: Duration) -> Self {
84        self.timeout = Some(timeout);
85        self
86    }
87
88    /// Reject a policy that cannot be enforced.
89    ///
90    /// A path granted both read-only and writable is ambiguous: whether a write
91    /// lands depends on backend and mount order, so it is refused rather than
92    /// silently resolved.
93    pub fn validate(&self) -> Result<(), SpecError> {
94        for path in &self.writable_paths {
95            if self.read_only_paths.contains(path) {
96                return Err(SpecError::ConflictingPath { path: path.clone() });
97            }
98        }
99        Ok(())
100    }
101}
102
103/// A command to run inside a sandbox.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub struct SandboxCommand {
107    /// Program to execute; not passed through a shell.
108    pub program: String,
109    /// Arguments, passed verbatim.
110    pub args: Vec<String>,
111    /// Working directory inside the sandbox.
112    pub cwd: Option<PathBuf>,
113}
114
115impl SandboxCommand {
116    /// A command with no arguments and no working directory.
117    pub fn new(program: impl Into<String>) -> Self {
118        Self {
119            program: program.into(),
120            args: Vec::new(),
121            cwd: None,
122        }
123    }
124
125    /// Append an argument.
126    #[must_use]
127    pub fn arg(mut self, arg: impl Into<String>) -> Self {
128        self.args.push(arg.into());
129        self
130    }
131
132    /// Set the working directory.
133    #[must_use]
134    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
135        self.cwd = Some(cwd.into());
136        self
137    }
138}
139
140/// What a sandboxed command produced.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SandboxOutput {
143    /// Exit status, or `-1` when terminated by a signal.
144    pub status: i32,
145    pub stdout: Vec<u8>,
146    pub stderr: Vec<u8>,
147}
148
149impl SandboxOutput {
150    /// Whether the command exited zero.
151    pub fn success(&self) -> bool {
152        self.status == 0
153    }
154}
155
156/// Why a sandbox could not run a command.
157#[derive(Debug)]
158pub enum SandboxError {
159    /// The policy is internally inconsistent.
160    Spec(SpecError),
161    /// Spawning the command failed.
162    Spawn(io::Error),
163    /// The backend does not implement the requested capability.
164    Unsupported(&'static str),
165    /// A read-only grant cannot be expressed by this backend.
166    UnsupportedReadOnlyShare { path: PathBuf },
167}
168
169impl std::fmt::Display for SandboxError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            Self::Spec(err) => write!(f, "invalid sandbox policy: {err}"),
173            Self::Spawn(err) => write!(f, "sandbox spawn failed: {err}"),
174            Self::Unsupported(what) => write!(f, "sandbox backend does not support {what}"),
175            Self::UnsupportedReadOnlyShare { path, .. } => write!(
176                f,
177                "sandbox backend cannot express {} as read-only",
178                path.display()
179            ),
180        }
181    }
182}
183
184impl std::error::Error for SandboxError {}
185
186impl From<SpecError> for SandboxError {
187    fn from(err: SpecError) -> Self {
188        Self::Spec(err)
189    }
190}
191
192impl From<io::Error> for SandboxError {
193    fn from(err: io::Error) -> Self {
194        Self::Spawn(err)
195    }
196}
197
198/// Why a [`SandboxSpec`] is not enforceable.
199#[derive(Debug, PartialEq, Eq)]
200pub enum SpecError {
201    /// The same path was granted read-only and writable.
202    ConflictingPath { path: PathBuf },
203}
204
205impl std::fmt::Display for SpecError {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Self::ConflictingPath { path } => {
209                write!(f, "{} is both read-only and writable", path.display())
210            }
211        }
212    }
213}
214
215impl std::error::Error for SpecError {}
216
217/// Executes commands under an isolation policy.
218///
219/// Implementations are `Send + Sync` so a backend can be shared across the
220/// CLI's worker threads, matching how `consortium_integration::exec::Executor`
221/// is threaded.
222pub trait Sandbox: Send + Sync {
223    /// Backend name, for diagnostics.
224    fn name(&self) -> &'static str;
225
226    /// Whether this backend actually enforces isolation.
227    ///
228    /// A backend that cannot honour the policy must return `false` here rather
229    /// than pretend. Callers decide whether a degraded run is acceptable; the
230    /// backend never decides it silently.
231    fn is_isolated(&self) -> bool;
232
233    /// Run `command` under `spec`.
234    fn exec(
235        &self,
236        spec: &SandboxSpec,
237        command: &SandboxCommand,
238    ) -> Result<SandboxOutput, SandboxError>;
239}
240
241/// A backend that enforces nothing; the default when no hypervisor is
242/// available.
243///
244/// It exists so the trait has a working implementation and so an unprotected
245/// run is explicitly reported by [`Sandbox::is_isolated`] rather than assumed.
246#[derive(Debug, Clone, Copy, Default)]
247pub struct DirectSandbox;
248
249impl Sandbox for DirectSandbox {
250    fn name(&self) -> &'static str {
251        "direct"
252    }
253
254    fn is_isolated(&self) -> bool {
255        false
256    }
257
258    fn exec(
259        &self,
260        spec: &SandboxSpec,
261        command: &SandboxCommand,
262    ) -> Result<SandboxOutput, SandboxError> {
263        spec.validate()?;
264        if spec.network == NetworkPolicy::Deny {
265            return Err(SandboxError::Unsupported("a network-denying policy"));
266        }
267
268        let mut cmd = std::process::Command::new(&command.program);
269        cmd.args(&command.args);
270        for (key, value) in &spec.env {
271            cmd.env(key, value);
272        }
273        if let Some(cwd) = &command.cwd {
274            cmd.current_dir(cwd);
275        }
276        cmd.stdin(std::process::Stdio::null());
277
278        let output = cmd.output()?;
279        Ok(SandboxOutput {
280            status: output.status.code().unwrap_or(-1),
281            stdout: output.stdout,
282            stderr: output.stderr,
283        })
284    }
285}
286
287#[cfg(feature = "vfkit")]
288pub mod vfkit;
289
290// A hypervisor backend lands here behind `#[cfg(feature = "libkrun")]`
291// once one exists that builds on this platform. libkrun itself supports
292// HVF on macOS/ARM64, but nixpkgs packages it for Linux only
293// (meta.platforms = x86_64-linux, aarch64-linux, riscv64-linux), so a
294// Nix-built backend cannot be exercised on darwin. On macOS,
295// nixpkgs ships `vfkit` (aarch64-darwin) as the Virtualization.framework
296// equivalent. Either way the feature stays off until a backend is
297// compiled and run on the host that will use it.