Skip to main content

sim_platform_ubuntu_pc/
sandbox.rs

1// conformance: Ubuntu sandbox realization refuses undeclared authority before spawning.
2
3use 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/// Linux bubblewrap realization of the runtime-owned sandbox authority boundary.
15#[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    /// Creates a boot-configured launcher. Paths are authority supplied, never request supplied.
24    #[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}