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)
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}