1use sim_lib_exec::{
4 BindingValue, MountAccess, ProcessAttempt, ProcessBudget, ProcessCancellation, ProcessRequest,
5 ProgramRef, ProjectRootRef, SandboxAttempt, SandboxControl, SandboxEvidence, SandboxLauncher,
6 SandboxRefusal, SandboxReport, SandboxRequest, SandboxResult,
7};
8use std::{
9 collections::BTreeMap,
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12};
13
14#[derive(Clone, Debug)]
16pub struct BwrapLauncher {
17 bwrap: PathBuf,
18 prlimit: PathBuf,
19 programs: BTreeMap<ProgramRef, PathBuf>,
20 sources: BTreeMap<String, PathBuf>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct BwrapConfinementStatus {
31 pub membrane: &'static str,
33 pub available: bool,
35 pub detail: String,
37}
38
39impl BwrapLauncher {
40 #[must_use]
42 pub fn new(
43 bwrap: PathBuf,
44 prlimit: PathBuf,
45 programs: BTreeMap<ProgramRef, PathBuf>,
46 sources: BTreeMap<String, PathBuf>,
47 ) -> Self {
48 Self {
49 bwrap,
50 prlimit,
51 programs,
52 sources,
53 }
54 }
55
56 #[must_use]
58 pub fn confinement_status(&self) -> BwrapConfinementStatus {
59 let bwrap = self.bwrap.is_file();
60 let prlimit = self.prlimit.is_file();
61 BwrapConfinementStatus {
62 membrane: "platform/sandbox/ubuntu-bwrap",
63 available: bwrap && prlimit,
64 detail: format!("bwrap-file={bwrap};prlimit-file={prlimit};purity-qualified=false"),
65 }
66 }
67 fn refuse(&self, reason: impl Into<String>) -> SandboxAttempt {
68 SandboxAttempt::Refused(SandboxRefusal {
69 launcher: self.id().into(),
70 reason: reason.into(),
71 report: None,
72 })
73 }
74 fn command(&self, request: &SandboxRequest) -> Result<Command, String> {
75 if !self.bwrap.is_file() {
76 return Err("bubblewrap is unavailable".into());
77 }
78 if !self.prlimit.is_file() {
79 return Err("prlimit is unavailable".into());
80 }
81 let program = canonical_file(
82 self.programs
83 .get(&request.program)
84 .ok_or("program is not boot-authorized")?,
85 )?;
86 let mut command = Command::new(&self.bwrap);
87 command
88 .args([
89 "--die-with-parent",
90 "--new-session",
91 "--unshare-all",
92 "--unshare-net",
93 "--clearenv",
94 "--tmpfs",
95 "/",
96 "--proc",
97 "/proc",
98 "--dev",
99 "/dev",
100 "--dir",
101 "/work",
102 "--chdir",
103 "/work",
104 "--ro-bind",
105 ])
106 .arg(&program)
107 .arg("/sim-program")
108 .args(["--ro-bind"])
109 .arg(&self.prlimit)
110 .arg("/sim-prlimit");
111 for mount in request.policy.mounts() {
112 let source = canonical(
113 self.sources
114 .get(&mount.source)
115 .ok_or("mount source is not boot-authorized")?,
116 )?;
117 command
118 .arg(match mount.access {
119 MountAccess::ReadOnly => "--ro-bind",
120 MountAccess::Writable => "--bind",
121 })
122 .arg(source)
123 .arg(&mount.guest_path);
124 }
125 for (name, value) in request.environment.iter() {
126 let BindingValue::Literal(value) = value else {
127 return Err("sandbox environment permits literal bindings only".into());
128 };
129 command.arg("--setenv").arg(name).arg(value);
130 }
131 let limits = request.policy.limits();
132 command
133 .args(["--", "/sim-prlimit"])
134 .arg(format!("--cpu={}", limits.cpu_seconds))
135 .arg(format!("--as={}", limits.memory_bytes))
136 .arg(format!("--nproc={}", limits.process_count))
137 .arg(format!("--fsize={}", limits.file_bytes))
138 .args(["--", "/sim-program"])
139 .args(request.argv.iter().map(sim_lib_exec::ArgAtom::as_str))
140 .stdin(Stdio::piped())
141 .stdout(Stdio::piped())
142 .stderr(Stdio::piped());
143 Ok(command)
144 }
145}
146impl SandboxLauncher for BwrapLauncher {
147 fn id(&self) -> &'static str {
148 "platform/sandbox/ubuntu-bwrap"
149 }
150 fn launch(
151 &self,
152 request: &SandboxRequest,
153 cancellation: &ProcessCancellation,
154 ) -> SandboxAttempt {
155 let mut command = match self.command(request) {
156 Ok(v) => v,
157 Err(e) => return self.refuse(e),
158 };
159 let root = ProjectRootRef::new("sandbox-root").expect("constant is valid");
160 let process_request = ProcessRequest {
161 program: request.program.clone(),
162 argv: request.argv.clone(),
163 root,
164 environment: request.environment.clone(),
165 private_artifacts: vec![],
166 budget: ProcessBudget {
167 timeout_ms: request.policy.limits().wall_time_ms,
168 max_output_bytes: request.policy.limits().output_bytes,
169 stdin: Some(request.stdin.clone()),
170 },
171 };
172 let mut child = match command.spawn() {
173 Ok(v) => v,
174 Err(e) => return self.refuse(format!("bubblewrap spawn failed: {e}")),
175 };
176 let outcome = super::process::run_child(&mut child, &process_request, cancellation);
177 report(request, outcome, &self.sources)
178 }
179}
180fn canonical(path: &Path) -> Result<PathBuf, String> {
181 path.canonicalize()
182 .map_err(|e| format!("declared mount unavailable: {e}"))
183}
184fn canonical_file(path: &Path) -> Result<PathBuf, String> {
185 let path = canonical(path)?;
186 if !path.is_file() {
187 return Err("authorized program is not a file".into());
188 }
189 Ok(path)
190}
191fn report(
192 request: &SandboxRequest,
193 outcome: ProcessAttempt,
194 sources: &BTreeMap<String, PathBuf>,
195) -> SandboxAttempt {
196 let usage = writable_usage(request, sources);
197 let usage_observed = usage.is_ok();
198 let (files, bytes) = usage.unwrap_or((u64::MAX, u64::MAX));
199 let controls = request
200 .policy
201 .requirements()
202 .keys()
203 .map(|control| SandboxEvidence {
204 control: *control,
205 achieved: !matches!(
206 control,
207 SandboxControl::FileCount | SandboxControl::FileBytes
208 ) || usage_observed,
209 detail: match control {
210 SandboxControl::Network => "bubblewrap network namespace has no interfaces",
211 SandboxControl::Mounts => "only canonical boot-resolved mounts were bound",
212 SandboxControl::Root => "anonymous tmpfs root; no home or workspace mount",
213 SandboxControl::Environment => "bubblewrap clearenv plus literal declared bindings",
214 SandboxControl::Identity => "user and mount namespaces isolate host identity",
215 SandboxControl::Cpu => "RLIMIT_CPU applied by prlimit",
216 SandboxControl::Memory => "RLIMIT_AS applied by prlimit",
217 SandboxControl::WallTime => "capsule monotonic deadline",
218 SandboxControl::ProcessCount => "RLIMIT_NPROC applied by prlimit",
219 SandboxControl::FileCount if usage_observed => {
220 "writable roots were inspected recursively at completion"
221 }
222 SandboxControl::FileCount => "writable-root file count could not be observed",
223 SandboxControl::FileBytes if usage_observed => {
224 "RLIMIT_FSIZE plus recursive writable-root byte inspection"
225 }
226 SandboxControl::FileBytes => "writable-root file bytes could not be observed",
227 SandboxControl::Output => "shared bounded capture",
228 SandboxControl::Stdin => "validated bounded pipe",
229 SandboxControl::ProcessTree => "new session killed and reaped by capsule",
230 }
231 .into(),
232 })
233 .collect();
234 match outcome {
235 ProcessAttempt::Completed { receipt } => {
236 let mut hits = vec![];
237 if receipt.result.truncated {
238 hits.push("output_bytes".into());
239 }
240 if usage_observed {
241 if files > request.policy.limits().file_count {
242 hits.push("file_count".into());
243 }
244 if bytes > request.policy.limits().file_bytes {
245 hits.push("file_bytes".into());
246 }
247 } else {
248 hits.push("writable_root_observation".into());
249 }
250 SandboxAttempt::Completed(SandboxResult {
251 stdout: receipt.result.stdout.into_bytes(),
252 stderr: receipt.result.stderr.into_bytes(),
253 exit_code: receipt.result.exit_code,
254 report: SandboxReport {
255 launcher: "platform/sandbox/ubuntu-bwrap".into(),
256 controls,
257 limit_hits: hits,
258 cleanup: "normal completion; process group empty after pipe closure".into(),
259 },
260 })
261 }
262 ProcessAttempt::StoppedAfterTimeout { receipt } => SandboxAttempt::Stopped(SandboxReport {
263 launcher: "platform/sandbox/ubuntu-bwrap".into(),
264 controls,
265 limit_hits: vec!["wall_time".into()],
266 cleanup: receipt.cleanup,
267 }),
268 ProcessAttempt::StoppedAfterCancel { receipt } => SandboxAttempt::Stopped(SandboxReport {
269 launcher: "platform/sandbox/ubuntu-bwrap".into(),
270 controls,
271 limit_hits: vec!["cancellation".into()],
272 cleanup: receipt.cleanup,
273 }),
274 ProcessAttempt::NotDispatched { refusal } => SandboxAttempt::Refused(SandboxRefusal {
275 launcher: "platform/sandbox/ubuntu-bwrap".into(),
276 reason: match refusal {
277 sim_lib_exec::ProcessRefusal::Invalid(detail) => format!("invalid: {detail}"),
278 sim_lib_exec::ProcessRefusal::Refused(detail) => format!("refused: {detail}"),
279 sim_lib_exec::ProcessRefusal::SpawnFailed(detail) => {
280 format!("spawn failed: {detail}")
281 }
282 },
283 report: None,
284 }),
285 ProcessAttempt::UnknownAfterDispatch { evidence } => {
286 SandboxAttempt::Unknown(SandboxRefusal {
287 launcher: "platform/sandbox/ubuntu-bwrap".into(),
288 reason: format!("{}: {}", evidence.stage, evidence.detail),
289 report: None,
290 })
291 }
292 }
293}
294
295fn writable_usage(
296 request: &SandboxRequest,
297 sources: &BTreeMap<String, PathBuf>,
298) -> Result<(u64, u64), String> {
299 let mut total = (0u64, 0u64);
300 for mount in request
301 .policy
302 .mounts()
303 .iter()
304 .filter(|mount| mount.access == MountAccess::Writable)
305 {
306 let root = canonical(
307 sources
308 .get(&mount.source)
309 .ok_or("writable mount source is not boot-authorized")?,
310 )?;
311 accumulate_usage(&root, &mut total)?;
312 }
313 Ok(total)
314}
315
316fn accumulate_usage(path: &Path, total: &mut (u64, u64)) -> Result<(), String> {
317 for entry in std::fs::read_dir(path).map_err(|error| format!("writable root: {error}"))? {
318 let entry = entry.map_err(|error| format!("writable entry: {error}"))?;
319 let metadata = std::fs::symlink_metadata(entry.path())
320 .map_err(|error| format!("writable metadata: {error}"))?;
321 if metadata.file_type().is_symlink() {
322 total.0 = total.0.saturating_add(1);
323 } else if metadata.is_dir() {
324 accumulate_usage(&entry.path(), total)?;
325 } else {
326 total.0 = total.0.saturating_add(1);
327 total.1 = total.1.saturating_add(metadata.len());
328 }
329 }
330 Ok(())
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use sim_lib_exec::{
337 ArgAtom, SandboxLimits, SandboxMount, SandboxPolicy, SandboxRequirement, SealedBindings,
338 };
339 fn policy() -> SandboxPolicy {
340 let controls = [
341 SandboxControl::Network,
342 SandboxControl::Mounts,
343 SandboxControl::Root,
344 SandboxControl::Environment,
345 SandboxControl::Identity,
346 SandboxControl::Cpu,
347 SandboxControl::Memory,
348 SandboxControl::WallTime,
349 SandboxControl::ProcessCount,
350 SandboxControl::FileCount,
351 SandboxControl::FileBytes,
352 SandboxControl::Output,
353 SandboxControl::Stdin,
354 SandboxControl::ProcessTree,
355 ];
356 SandboxPolicy::new(
357 controls
358 .into_iter()
359 .map(|c| (c, SandboxRequirement::Required)),
360 vec![SandboxMount {
361 source: "input".into(),
362 guest_path: "/input".into(),
363 access: MountAccess::ReadOnly,
364 }],
365 SandboxLimits {
366 cpu_seconds: 1,
367 memory_bytes: 1024 * 1024,
368 wall_time_ms: 100,
369 process_count: 2,
370 file_count: 2,
371 file_bytes: 1024,
372 output_bytes: 1024,
373 stdin_bytes: 16,
374 },
375 )
376 .unwrap()
377 }
378 #[test]
379 fn missing_bwrap_refuses_before_dispatch() {
380 let launcher = BwrapLauncher::new(
381 "/definitely/missing/bwrap".into(),
382 "/usr/bin/prlimit".into(),
383 BTreeMap::new(),
384 BTreeMap::new(),
385 );
386 let status = launcher.confinement_status();
387 assert!(!status.available);
388 assert_eq!(status.membrane, "platform/sandbox/ubuntu-bwrap");
389 assert!(status.detail.contains("purity-qualified=false"));
390 assert_eq!(launcher.id(), "platform/sandbox/ubuntu-bwrap");
391 let request = SandboxRequest::new(
392 ProgramRef::new("tool").unwrap(),
393 vec![],
394 SealedBindings::empty(),
395 vec![],
396 policy(),
397 )
398 .unwrap();
399 assert!(matches!(
400 launcher.launch(&request, &ProcessCancellation::default()),
401 SandboxAttempt::Refused(_)
402 ));
403 }
404 #[test]
405 fn command_is_anonymous_networkless_and_keeps_hostile_argument_literal() {
406 let executable = std::env::current_exe().unwrap();
407 let launcher = BwrapLauncher::new(
408 executable.clone(),
409 executable.clone(),
410 BTreeMap::from([(ProgramRef::new("tool").unwrap(), executable)]),
411 BTreeMap::from([("input".into(), PathBuf::from("/tmp"))]),
412 );
413 let hostile = "$(cat /etc/shadow); nc 127.0.0.1 1";
414 let request = SandboxRequest::new(
415 ProgramRef::new("tool").unwrap(),
416 vec![ArgAtom::new(hostile).unwrap()],
417 SealedBindings::empty(),
418 vec![],
419 policy(),
420 )
421 .unwrap();
422 let command = launcher.command(&request).unwrap();
423 let args = command
424 .get_args()
425 .map(|v| v.to_string_lossy().into_owned())
426 .collect::<Vec<_>>();
427 assert!(
428 args.iter().any(|v| v == "--unshare-net") && args.iter().any(|v| v == "--clearenv")
429 );
430 assert!(args.iter().any(|v| v == hostile));
431 assert!(!args.iter().any(|v| v == "/home" || v == "/workspace"));
432 }
433}