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}
22
23/// Live readiness of the Ubuntu bubblewrap effect membrane.
24///
25/// This evidence certifies only bounded-effect confinement. It deliberately
26/// cannot represent projector purity or source qualification: bubblewrap
27/// exposes `/proc` and `/dev`, and declared mounts may contain semantic inputs
28/// outside a projector's selected immutable view.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct BwrapConfinementStatus {
31    /// Stable membrane implementation identity.
32    pub membrane: &'static str,
33    /// Whether both boot-authorized executables exist as files now.
34    pub available: bool,
35    /// Human-readable readiness detail.
36    pub detail: String,
37}
38
39impl BwrapLauncher {
40    /// Creates a boot-configured launcher. Paths are authority supplied, never request supplied.
41    #[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    /// Probes the exact boot-resolved membrane executables without dispatch.
57    #[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}