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)
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(request: &SandboxRequest, outcome: ProcessAttempt) -> SandboxAttempt {
163 let controls = request
164 .policy
165 .requirements()
166 .keys()
167 .map(|control| SandboxEvidence {
168 control: *control,
169 achieved: true,
170 detail: match control {
171 SandboxControl::Network => "bubblewrap network namespace has no interfaces",
172 SandboxControl::Mounts => "only canonical boot-resolved mounts were bound",
173 SandboxControl::Root => "anonymous tmpfs root; no home or workspace mount",
174 SandboxControl::Environment => "bubblewrap clearenv plus literal declared bindings",
175 SandboxControl::Identity => "user and mount namespaces isolate host identity",
176 SandboxControl::Cpu => "RLIMIT_CPU applied by prlimit",
177 SandboxControl::Memory => "RLIMIT_AS applied by prlimit",
178 SandboxControl::WallTime => "capsule monotonic deadline",
179 SandboxControl::ProcessCount => "RLIMIT_NPROC applied by prlimit",
180 SandboxControl::FileCount => {
181 "writable roots are declaration-bounded and inspected at completion"
182 }
183 SandboxControl::FileBytes => "RLIMIT_FSIZE applied by prlimit",
184 SandboxControl::Output => "shared bounded capture",
185 SandboxControl::Stdin => "validated bounded pipe",
186 SandboxControl::ProcessTree => "new session killed and reaped by capsule",
187 }
188 .into(),
189 })
190 .collect();
191 match outcome {
192 ProcessAttempt::Completed { receipt } => {
193 let mut hits = vec![];
194 if receipt.result.truncated {
195 hits.push("output_bytes".into());
196 }
197 SandboxAttempt::Completed(SandboxResult {
198 stdout: receipt.result.stdout.into_bytes(),
199 stderr: receipt.result.stderr.into_bytes(),
200 exit_code: receipt.result.exit_code,
201 report: SandboxReport {
202 launcher: "platform/sandbox/ubuntu-bwrap".into(),
203 controls,
204 limit_hits: hits,
205 cleanup: "normal completion; process group empty after pipe closure".into(),
206 },
207 })
208 }
209 ProcessAttempt::StoppedAfterTimeout { receipt } => SandboxAttempt::Stopped(SandboxReport {
210 launcher: "platform/sandbox/ubuntu-bwrap".into(),
211 controls,
212 limit_hits: vec!["wall_time".into()],
213 cleanup: receipt.cleanup,
214 }),
215 ProcessAttempt::StoppedAfterCancel { receipt } => SandboxAttempt::Stopped(SandboxReport {
216 launcher: "platform/sandbox/ubuntu-bwrap".into(),
217 controls,
218 limit_hits: vec!["cancellation".into()],
219 cleanup: receipt.cleanup,
220 }),
221 ProcessAttempt::NotDispatched { refusal } => SandboxAttempt::Refused(SandboxRefusal {
222 launcher: "platform/sandbox/ubuntu-bwrap".into(),
223 reason: format!("{refusal:?}"),
224 report: None,
225 }),
226 ProcessAttempt::UnknownAfterDispatch { evidence } => {
227 SandboxAttempt::Unknown(SandboxRefusal {
228 launcher: "platform/sandbox/ubuntu-bwrap".into(),
229 reason: format!("{evidence:?}"),
230 report: None,
231 })
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use sim_lib_exec::{
240 ArgAtom, SandboxLimits, SandboxMount, SandboxPolicy, SandboxRequirement, SealedBindings,
241 };
242 fn policy() -> SandboxPolicy {
243 let controls = [
244 SandboxControl::Network,
245 SandboxControl::Mounts,
246 SandboxControl::Root,
247 SandboxControl::Environment,
248 SandboxControl::Identity,
249 SandboxControl::Cpu,
250 SandboxControl::Memory,
251 SandboxControl::WallTime,
252 SandboxControl::ProcessCount,
253 SandboxControl::FileCount,
254 SandboxControl::FileBytes,
255 SandboxControl::Output,
256 SandboxControl::Stdin,
257 SandboxControl::ProcessTree,
258 ];
259 SandboxPolicy::new(
260 controls
261 .into_iter()
262 .map(|c| (c, SandboxRequirement::Required)),
263 vec![SandboxMount {
264 source: "input".into(),
265 guest_path: "/input".into(),
266 access: MountAccess::ReadOnly,
267 }],
268 SandboxLimits {
269 cpu_seconds: 1,
270 memory_bytes: 1024 * 1024,
271 wall_time_ms: 100,
272 process_count: 2,
273 file_count: 2,
274 file_bytes: 1024,
275 output_bytes: 1024,
276 stdin_bytes: 16,
277 },
278 )
279 .unwrap()
280 }
281 #[test]
282 fn missing_bwrap_refuses_before_dispatch() {
283 let launcher = BwrapLauncher::new(
284 "/definitely/missing/bwrap".into(),
285 "/usr/bin/prlimit".into(),
286 BTreeMap::new(),
287 BTreeMap::new(),
288 );
289 assert_eq!(launcher.id(), "platform/sandbox/ubuntu-bwrap");
290 let request = SandboxRequest::new(
291 ProgramRef::new("tool").unwrap(),
292 vec![],
293 SealedBindings::empty(),
294 vec![],
295 policy(),
296 )
297 .unwrap();
298 assert!(matches!(
299 launcher.launch(&request, &ProcessCancellation::default()),
300 SandboxAttempt::Refused(_)
301 ));
302 }
303 #[test]
304 fn command_is_anonymous_networkless_and_keeps_hostile_argument_literal() {
305 let launcher = BwrapLauncher::new(
306 "/usr/bin/bwrap".into(),
307 "/usr/bin/prlimit".into(),
308 BTreeMap::from([(ProgramRef::new("tool").unwrap(), PathBuf::from("/bin/true"))]),
309 BTreeMap::from([("input".into(), PathBuf::from("/tmp"))]),
310 );
311 let hostile = "$(cat /etc/shadow); nc 127.0.0.1 1";
312 let request = SandboxRequest::new(
313 ProgramRef::new("tool").unwrap(),
314 vec![ArgAtom::new(hostile).unwrap()],
315 SealedBindings::empty(),
316 vec![],
317 policy(),
318 )
319 .unwrap();
320 let command = launcher.command(&request).unwrap();
321 let args = command
322 .get_args()
323 .map(|v| v.to_string_lossy().into_owned())
324 .collect::<Vec<_>>();
325 assert!(
326 args.iter().any(|v| v == "--unshare-net") && args.iter().any(|v| v == "--clearenv")
327 );
328 assert!(args.iter().any(|v| v == hostile));
329 assert!(!args.iter().any(|v| v == "/home" || v == "/workspace"));
330 }
331}