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