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.