Skip to main content

harn_hostlib/sandbox/
local.rs

1//! Local enforcement backend.
2//!
3//! Runs each command through `harn-vm`'s process sandbox, so the
4//! kernel-level confinement (Landlock/seccomp on Linux, `sandbox-exec`
5//! on macOS, Job Objects on Windows, `pledge`/`unveil` on OpenBSD) is
6//! reused rather than reimplemented. Filesystem scope comes from the
7//! session's mounts; network egress is limited to deny-all or
8//! allow-all, since per-host egress filtering for a local process is a
9//! remote-backend capability (see [`SandboxCapabilities::network_policy`]).
10
11use std::collections::{BTreeMap, HashMap};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15use async_trait::async_trait;
16use harn_vm::orchestration::{
17    pop_execution_policy, push_execution_policy, CapabilityPolicy, SandboxProfile,
18};
19use harn_vm::{compile_source, stdlib::register_vm_stdlib, Vm, VmValue};
20use tempfile::TempDir;
21
22use super::{
23    harn_string, normalized_mount_target, ExecRequest, ExecResult, FilesystemAccess,
24    FilesystemMount, NetworkPolicy, ResolvedMount, ResourceLimits, SandboxBackend,
25    SandboxCapabilities, SandboxError, SandboxResult, SandboxSession, SandboxSessionId,
26    SandboxSnapshot, SandboxSpec, SandboxState, MEMORY_MOUNT, OUTPUTS_MOUNT,
27};
28
29/// Configuration for a [`LocalSandbox`].
30#[derive(Clone, Debug)]
31pub struct LocalSandboxConfig {
32    /// Directory under which session roots are created. When `None`,
33    /// sessions are rooted under the current working directory.
34    pub root_dir: Option<PathBuf>,
35    /// The `harn-vm` sandbox profile applied to every command in this
36    /// backend.
37    pub sandbox_profile: SandboxProfile,
38}
39
40impl Default for LocalSandboxConfig {
41    fn default() -> Self {
42        Self {
43            root_dir: None,
44            sandbox_profile: SandboxProfile::OsHardened,
45        }
46    }
47}
48
49/// Local [`SandboxBackend`] that confines commands with `harn-vm`'s
50/// process sandbox.
51#[derive(Clone, Debug)]
52pub struct LocalSandbox {
53    config: LocalSandboxConfig,
54    sessions: Arc<Mutex<HashMap<SandboxSessionId, Arc<LocalSession>>>>,
55}
56
57impl LocalSandbox {
58    /// Construct a backend with the given configuration.
59    pub fn new(config: LocalSandboxConfig) -> Self {
60        Self {
61            config,
62            sessions: Arc::new(Mutex::new(HashMap::new())),
63        }
64    }
65
66    fn session(&self, session_id: &SandboxSessionId) -> SandboxResult<Arc<LocalSession>> {
67        self.sessions
68            .lock()
69            .map_err(|_| SandboxError::Lifecycle("local session lock poisoned".to_string()))?
70            .get(session_id)
71            .cloned()
72            .ok_or_else(|| SandboxError::SessionNotFound(session_id.to_string()))
73    }
74}
75
76impl Default for LocalSandbox {
77    fn default() -> Self {
78        Self::new(LocalSandboxConfig::default())
79    }
80}
81
82#[async_trait]
83impl SandboxBackend for LocalSandbox {
84    fn name(&self) -> &'static str {
85        "local"
86    }
87
88    fn capabilities(&self) -> SandboxCapabilities {
89        SandboxCapabilities {
90            local_process_sandbox: true,
91            network_policy: false,
92            snapshot: true,
93            resume: true,
94            suspend_on_idle: false,
95        }
96    }
97
98    async fn provision(&self, mut spec: SandboxSpec) -> SandboxResult<SandboxSession> {
99        let id = spec.session_id.take().unwrap_or_else(|| {
100            SandboxSessionId(format!("local-{}", uuid::Uuid::now_v7().simple()))
101        });
102        let tempdir = match &self.config.root_dir {
103            Some(root) => tempfile::Builder::new()
104                .prefix("harn-sandbox-")
105                .tempdir_in(root)?,
106            None => tempfile::Builder::new()
107                .prefix("harn-sandbox-")
108                .tempdir_in(std::env::current_dir()?)?,
109        };
110
111        let root = tempdir.path().to_path_buf();
112        let memory = root.join("mnt/memory");
113        let outputs = root.join("mnt/session/outputs");
114        std::fs::create_dir_all(&memory)?;
115        std::fs::create_dir_all(&outputs)?;
116
117        let mut mounts = vec![
118            ResolvedMount {
119                target: MEMORY_MOUNT.to_string(),
120                access: FilesystemAccess::ReadWrite,
121                host_path: Some(memory),
122            },
123            ResolvedMount {
124                target: OUTPUTS_MOUNT.to_string(),
125                access: FilesystemAccess::ReadWrite,
126                host_path: Some(outputs),
127            },
128        ];
129        for mount in spec.mounts {
130            mounts.push(resolve_local_mount(&root, mount)?);
131        }
132
133        let session = Arc::new(LocalSession {
134            id: id.clone(),
135            tempdir,
136            mounts: Mutex::new(mounts),
137            network_policy: Mutex::new(spec.network_policy),
138            limits: spec.limits,
139            state: Mutex::new(SandboxState::Running),
140            sandbox_profile: self.config.sandbox_profile,
141        });
142
143        self.sessions
144            .lock()
145            .map_err(|_| SandboxError::Lifecycle("local session lock poisoned".to_string()))?
146            .insert(id, session.clone());
147
148        session.to_public()
149    }
150
151    async fn attach_filesystem(
152        &self,
153        session_id: &SandboxSessionId,
154        mount: FilesystemMount,
155    ) -> SandboxResult<SandboxSession> {
156        let session = self.session(session_id)?;
157        let resolved = resolve_local_mount(session.tempdir.path(), mount)?;
158        session
159            .mounts
160            .lock()
161            .map_err(|_| SandboxError::Lifecycle("local mount lock poisoned".to_string()))?
162            .push(resolved);
163        session.to_public()
164    }
165
166    async fn apply_network_policy(
167        &self,
168        session_id: &SandboxSessionId,
169        policy: NetworkPolicy,
170    ) -> SandboxResult<SandboxSession> {
171        if let NetworkPolicy::Limited { allowed_hosts } = &policy {
172            if !allowed_hosts.is_empty() {
173                return Err(SandboxError::Unsupported {
174                    backend: "local",
175                    operation: "limited network allow-lists",
176                });
177            }
178        }
179        let session = self.session(session_id)?;
180        *session
181            .network_policy
182            .lock()
183            .map_err(|_| SandboxError::Lifecycle("local network lock poisoned".to_string()))? =
184            policy;
185        session.to_public()
186    }
187
188    async fn exec(
189        &self,
190        session_id: &SandboxSessionId,
191        request: ExecRequest,
192    ) -> SandboxResult<ExecResult> {
193        let session = self.session(session_id)?;
194        session.exec(request).await
195    }
196
197    async fn snapshot(&self, session_id: &SandboxSessionId) -> SandboxResult<SandboxSnapshot> {
198        let session = self.session(session_id)?;
199        Ok(SandboxSnapshot {
200            session_id: session.id.clone(),
201            backend: "local".to_string(),
202            snapshot_id: format!("local:{}", session.id),
203            metadata: BTreeMap::from([(
204                "root".to_string(),
205                session.tempdir.path().display().to_string(),
206            )]),
207        })
208    }
209
210    async fn resume(&self, session_id: &SandboxSessionId) -> SandboxResult<SandboxSession> {
211        let session = self.session(session_id)?;
212        *session
213            .state
214            .lock()
215            .map_err(|_| SandboxError::Lifecycle("local state lock poisoned".to_string()))? =
216            SandboxState::Running;
217        session.to_public()
218    }
219
220    async fn terminate(&self, session_id: &SandboxSessionId) -> SandboxResult<()> {
221        let session = self
222            .sessions
223            .lock()
224            .map_err(|_| SandboxError::Lifecycle("local session lock poisoned".to_string()))?
225            .remove(session_id)
226            .ok_or_else(|| SandboxError::SessionNotFound(session_id.to_string()))?;
227        *session
228            .state
229            .lock()
230            .map_err(|_| SandboxError::Lifecycle("local state lock poisoned".to_string()))? =
231            SandboxState::Terminated;
232        Ok(())
233    }
234}
235
236#[derive(Debug)]
237struct LocalSession {
238    id: SandboxSessionId,
239    tempdir: TempDir,
240    mounts: Mutex<Vec<ResolvedMount>>,
241    network_policy: Mutex<NetworkPolicy>,
242    limits: ResourceLimits,
243    state: Mutex<SandboxState>,
244    sandbox_profile: SandboxProfile,
245}
246
247impl LocalSession {
248    fn to_public(&self) -> SandboxResult<SandboxSession> {
249        let mounts = self
250            .mounts
251            .lock()
252            .map_err(|_| SandboxError::Lifecycle("local mount lock poisoned".to_string()))?
253            .clone();
254        let state = self
255            .state
256            .lock()
257            .map_err(|_| SandboxError::Lifecycle("local state lock poisoned".to_string()))?
258            .clone();
259        Ok(SandboxSession {
260            id: self.id.clone(),
261            backend: "local".to_string(),
262            state,
263            mounts,
264            metadata: BTreeMap::from([(
265                "root".to_string(),
266                self.tempdir.path().display().to_string(),
267            )]),
268        })
269    }
270
271    async fn exec(self: Arc<Self>, request: ExecRequest) -> SandboxResult<ExecResult> {
272        if request.command.trim().is_empty() {
273            return Err(SandboxError::InvalidRequest(
274                "exec command cannot be empty".to_string(),
275            ));
276        }
277        let source = self.harn_exec_source(&request)?;
278        let policy = self.execution_policy()?;
279
280        let task = tokio::task::spawn_blocking(move || run_harn_shell(source, policy));
281        task.await?
282    }
283
284    fn harn_exec_source(&self, request: &ExecRequest) -> SandboxResult<String> {
285        let cwd = self.resolve_cwd(request.cwd.as_deref())?;
286        let mut env = mount_env(&self.mounts()?);
287        for key in request.env.keys() {
288            validate_env_key(key)?;
289        }
290        env.extend(request.env.clone());
291
292        let mut options = vec![
293            format!("program: {}", harn_string(&request.command)),
294            format!("args: {}", harn_string_list(&request.args)),
295            format!("cwd: {}", harn_string(&cwd.display().to_string())),
296            format!("env: {}", harn_string_dict(&env)),
297        ];
298        if let Some(stdin) = &request.stdin {
299            options.push(format!("stdin: {}", harn_string(stdin)));
300        }
301        if let Some(timeout) = request.timeout.or(self.limits.wall_time) {
302            options.push(format!("timeout_ms: {}", duration_millis(timeout)));
303        }
304        Ok(format!(
305            "pipeline local_sandbox_exec(harness: Harness, task) {{ return harness.process.run({{{}}}) }}",
306            options.join(", "),
307        ))
308    }
309
310    fn execution_policy(&self) -> SandboxResult<CapabilityPolicy> {
311        // The session root is always writable; declared mounts split by
312        // their access so a `ReadOnly` mount lowers to a read-only root
313        // the VM and OS sandbox both refuse to write.
314        let mut roots = vec![self.tempdir.path().display().to_string()];
315        let mut read_only_roots = Vec::new();
316        for mount in self.mounts()? {
317            if let Some(path) = mount.host_path {
318                match mount.access {
319                    FilesystemAccess::ReadWrite => roots.push(path.display().to_string()),
320                    FilesystemAccess::ReadOnly => read_only_roots.push(path.display().to_string()),
321                }
322            }
323        }
324        let mut capabilities = BTreeMap::new();
325        capabilities.insert("process".to_string(), vec!["run".to_string()]);
326        capabilities.insert(
327            "workspace".to_string(),
328            vec![
329                "read_text".to_string(),
330                "list".to_string(),
331                "exists".to_string(),
332                "write_text".to_string(),
333                "delete".to_string(),
334            ],
335        );
336
337        Ok(CapabilityPolicy {
338            capabilities,
339            workspace_roots: roots,
340            read_only_roots,
341            side_effect_level: Some("process_exec".to_string()),
342            sandbox_profile: self.sandbox_profile,
343            ..Default::default()
344        })
345    }
346
347    fn resolve_cwd(&self, cwd: Option<&str>) -> SandboxResult<PathBuf> {
348        let Some(cwd) = cwd else {
349            return Ok(self.tempdir.path().to_path_buf());
350        };
351        if cwd.trim().is_empty() {
352            return Ok(self.tempdir.path().to_path_buf());
353        }
354        if let Some(path) = self.resolve_mount_path(cwd)? {
355            return Ok(path);
356        }
357        let path = PathBuf::from(cwd);
358        if path.is_absolute() {
359            return Ok(path);
360        }
361        Ok(self.tempdir.path().join(path))
362    }
363
364    fn resolve_mount_path(&self, path: &str) -> SandboxResult<Option<PathBuf>> {
365        if !path.trim_start().starts_with('/') {
366            return Ok(None);
367        }
368        let normalized = normalized_mount_target(path)?;
369        for mount in self.mounts()?.into_iter().rev() {
370            if normalized == mount.target || normalized.starts_with(&(mount.target.clone() + "/")) {
371                let Some(host_path) = mount.host_path else {
372                    continue;
373                };
374                let suffix = normalized
375                    .trim_start_matches(&mount.target)
376                    .trim_start_matches('/');
377                return Ok(Some(host_path.join(suffix)));
378            }
379        }
380        Ok(None)
381    }
382
383    fn mounts(&self) -> SandboxResult<Vec<ResolvedMount>> {
384        Ok(self
385            .mounts
386            .lock()
387            .map_err(|_| SandboxError::Lifecycle("local mount lock poisoned".to_string()))?
388            .clone())
389    }
390}
391
392fn resolve_local_mount(root: &Path, mount: FilesystemMount) -> SandboxResult<ResolvedMount> {
393    let target = normalized_mount_target(&mount.target)?;
394    let source = if mount.source.as_os_str().is_empty() {
395        let relative = target.trim_start_matches('/');
396        root.join(relative)
397    } else if mount.source.is_absolute() {
398        mount.source
399    } else {
400        root.join(mount.source)
401    };
402    std::fs::create_dir_all(&source)?;
403    Ok(ResolvedMount {
404        target,
405        access: mount.access,
406        host_path: Some(source),
407    })
408}
409
410fn mount_env(mounts: &[ResolvedMount]) -> BTreeMap<String, String> {
411    let mut env = BTreeMap::new();
412    for mount in mounts {
413        let Some(path) = &mount.host_path else {
414            continue;
415        };
416        if mount.target == MEMORY_MOUNT {
417            env.insert("HARN_MEMORY_DIR".to_string(), path.display().to_string());
418        }
419        if mount.target == OUTPUTS_MOUNT {
420            env.insert("HARN_OUTPUTS_DIR".to_string(), path.display().to_string());
421        }
422    }
423    env
424}
425
426fn harn_string_list(values: &[String]) -> String {
427    let items = values
428        .iter()
429        .map(|value| harn_string(value))
430        .collect::<Vec<_>>()
431        .join(", ");
432    format!("[{items}]")
433}
434
435fn harn_string_dict(values: &BTreeMap<String, String>) -> String {
436    let fields = values
437        .iter()
438        .map(|(key, value)| format!("{}: {}", harn_string(key), harn_string(value)))
439        .collect::<Vec<_>>()
440        .join(", ");
441    format!("{{{fields}}}")
442}
443
444fn duration_millis(duration: std::time::Duration) -> i64 {
445    duration.as_millis().clamp(1, i64::MAX as u128) as i64
446}
447
448fn validate_env_key(key: &str) -> SandboxResult<()> {
449    if key.is_empty()
450        || key
451            .chars()
452            .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
453        || key.as_bytes()[0].is_ascii_digit()
454    {
455        return Err(SandboxError::InvalidRequest(format!(
456            "invalid environment key `{key}`"
457        )));
458    }
459    Ok(())
460}
461
462fn run_harn_shell(source: String, policy: CapabilityPolicy) -> SandboxResult<ExecResult> {
463    let chunk = compile_source(&source).map_err(SandboxError::Exec)?;
464    let rt = tokio::runtime::Builder::new_current_thread()
465        .enable_all()
466        .build()
467        .map_err(SandboxError::Io)?;
468
469    rt.block_on(async {
470        let local = tokio::task::LocalSet::new();
471        local
472            .run_until(async move {
473                let _guard = ExecutionPolicyGuard::push(policy);
474                let mut vm = Vm::new();
475                register_vm_stdlib(&mut vm);
476                let value = vm.execute(&chunk).await.map_err(|error| {
477                    SandboxError::Exec(format!("harn-vm process sandbox rejected exec: {error}"))
478                })?;
479                exec_result_from_value(value)
480            })
481            .await
482    })
483}
484
485struct ExecutionPolicyGuard;
486
487impl ExecutionPolicyGuard {
488    fn push(policy: CapabilityPolicy) -> Self {
489        push_execution_policy(policy);
490        Self
491    }
492}
493
494impl Drop for ExecutionPolicyGuard {
495    fn drop(&mut self) {
496        pop_execution_policy();
497    }
498}
499
500fn exec_result_from_value(value: VmValue) -> SandboxResult<ExecResult> {
501    let VmValue::Dict(map) = value else {
502        return Err(SandboxError::Exec(format!(
503            "expected exec result dict from harn-vm, got {}",
504            value.display()
505        )));
506    };
507    let stdout = dict_string(&map, "stdout")?;
508    let stderr = dict_string(&map, "stderr")?;
509    // The typed Harness contract names the numeric field `exit_code`; legacy
510    // process results used numeric `status`, while lifecycle-aware results now
511    // use `status: "completed"`. Prefer the unambiguous typed field.
512    let exit_code = dict_int_any(&map, &["exit_code", "status"])?;
513    let timed_out = dict_bool_optional(&map, "timed_out")?.unwrap_or(false);
514    Ok(ExecResult {
515        stdout,
516        stderr,
517        exit_code,
518        timed_out,
519    })
520}
521
522fn dict_string(map: &harn_vm::value::DictMap, key: &str) -> SandboxResult<String> {
523    match map.get(key) {
524        Some(VmValue::String(value)) => Ok(value.to_string()),
525        Some(other) => Err(SandboxError::Exec(format!(
526            "expected `{key}` string, got {}",
527            other.display()
528        ))),
529        None => Err(SandboxError::Exec(format!(
530            "missing `{key}` in exec result"
531        ))),
532    }
533}
534
535fn dict_int(map: &harn_vm::value::DictMap, key: &str) -> SandboxResult<i32> {
536    match map.get(key) {
537        Some(VmValue::Int(value)) => Ok(*value as i32),
538        Some(other) => Err(SandboxError::Exec(format!(
539            "expected `{key}` int, got {}",
540            other.display()
541        ))),
542        None => Err(SandboxError::Exec(format!(
543            "missing `{key}` in exec result"
544        ))),
545    }
546}
547
548fn dict_int_any(map: &harn_vm::value::DictMap, keys: &[&str]) -> SandboxResult<i32> {
549    for key in keys {
550        if map.contains_key(*key) {
551            return dict_int(map, key);
552        }
553    }
554    Err(SandboxError::Exec(format!(
555        "missing any of `{}` in exec result",
556        keys.join("`, `")
557    )))
558}
559
560fn dict_bool_optional(map: &harn_vm::value::DictMap, key: &str) -> SandboxResult<Option<bool>> {
561    match map.get(key) {
562        Some(VmValue::Bool(value)) => Ok(Some(*value)),
563        Some(other) => Err(SandboxError::Exec(format!(
564            "expected `{key}` bool, got {}",
565            other.display()
566        ))),
567        None => Ok(None),
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    // Exercises a real `sh -c` invocation with POSIX env expansion and
576    // `printf`, so it only runs where a POSIX shell exists.
577    #[cfg(unix)]
578    #[tokio::test]
579    async fn local_backend_execs_inside_session_outputs() {
580        let backend = LocalSandbox::default();
581        let session = backend.provision(SandboxSpec::default()).await.unwrap();
582
583        let result = backend
584            .exec(
585                &session.id,
586                ExecRequest {
587                    command: "sh".to_string(),
588                    args: vec![
589                        "-c".to_string(),
590                        "printf ok > \"$HARN_OUTPUTS_DIR/result.txt\" && cat \"$HARN_OUTPUTS_DIR/result.txt\""
591                            .to_string(),
592                    ],
593                    ..Default::default()
594                },
595            )
596            .await
597            .unwrap();
598
599        assert_eq!(result.exit_code, 0, "{result:?}");
600        assert_eq!(result.stdout, "ok");
601    }
602
603    #[cfg(unix)]
604    #[tokio::test]
605    async fn local_backend_timeout_is_enforced_without_shell_timeout_binary() {
606        let backend = LocalSandbox::default();
607        let session = backend.provision(SandboxSpec::default()).await.unwrap();
608
609        let result = backend
610            .exec(
611                &session.id,
612                ExecRequest {
613                    command: "sh".to_string(),
614                    args: vec!["-c".to_string(), "sleep 5".to_string()],
615                    timeout: Some(std::time::Duration::from_millis(25)),
616                    ..Default::default()
617                },
618            )
619            .await
620            .unwrap();
621
622        assert!(result.timed_out, "{result:?}");
623        assert_eq!(result.exit_code, -1, "{result:?}");
624    }
625
626    #[tokio::test]
627    async fn local_backend_rejects_limited_network_policy() {
628        let backend = LocalSandbox::default();
629        let session = backend.provision(SandboxSpec::default()).await.unwrap();
630        let deny_all = backend
631            .apply_network_policy(
632                &session.id,
633                NetworkPolicy::Limited {
634                    allowed_hosts: Vec::new(),
635                },
636            )
637            .await
638            .expect("deny-all egress policy is enforceable locally");
639        assert_eq!(deny_all.id, session.id);
640
641        let err = backend
642            .apply_network_policy(
643                &session.id,
644                NetworkPolicy::Limited {
645                    allowed_hosts: vec!["example.com".to_string()],
646                },
647            )
648            .await
649            .unwrap_err();
650
651        assert!(matches!(err, SandboxError::Unsupported { .. }));
652    }
653
654    #[tokio::test]
655    async fn local_backend_defaults_to_os_hardened_sandbox_profile() {
656        let backend = LocalSandbox::default();
657        let session = backend.provision(SandboxSpec::default()).await.unwrap();
658        let local = backend.session(&session.id).unwrap();
659
660        let policy = local.execution_policy().unwrap();
661
662        assert_eq!(policy.sandbox_profile, SandboxProfile::OsHardened);
663    }
664
665    #[tokio::test]
666    async fn local_backend_threads_configured_sandbox_profile_into_policy() {
667        let backend = LocalSandbox::new(LocalSandboxConfig {
668            root_dir: None,
669            sandbox_profile: SandboxProfile::Unrestricted,
670        });
671        let session = backend.provision(SandboxSpec::default()).await.unwrap();
672        let local = backend.session(&session.id).unwrap();
673
674        let policy = local.execution_policy().unwrap();
675
676        assert_eq!(policy.sandbox_profile, SandboxProfile::Unrestricted);
677    }
678
679    #[tokio::test]
680    async fn read_only_mounts_lower_to_read_only_roots() {
681        let backend = LocalSandbox::default();
682        let session = backend
683            .provision(SandboxSpec {
684                mounts: vec![FilesystemMount {
685                    source: PathBuf::new(),
686                    target: "/mnt/reference".to_string(),
687                    access: FilesystemAccess::ReadOnly,
688                }],
689                ..Default::default()
690            })
691            .await
692            .unwrap();
693        let local = backend.session(&session.id).unwrap();
694
695        let policy = local.execution_policy().unwrap();
696
697        // The canonical memory/outputs mounts plus the session root stay
698        // writable; only the declared read-only mount lands in read_only_roots.
699        assert!(
700            policy
701                .read_only_roots
702                .iter()
703                .any(|root| root.ends_with("reference")),
704            "read-only mount should lower to read_only_roots, got {:?}",
705            policy.read_only_roots
706        );
707        assert!(
708            !policy
709                .workspace_roots
710                .iter()
711                .any(|root| root.ends_with("reference")),
712            "read-only mount must not appear among writable workspace_roots, got {:?}",
713            policy.workspace_roots
714        );
715    }
716
717    #[test]
718    fn mount_env_uses_canonical_mount_names() {
719        let mounts = vec![ResolvedMount {
720            target: OUTPUTS_MOUNT.to_string(),
721            access: FilesystemAccess::ReadWrite,
722            host_path: Some(PathBuf::from("/tmp/out")),
723        }];
724        assert_eq!(
725            mount_env(&mounts).get("HARN_OUTPUTS_DIR"),
726            Some(&"/tmp/out".to_string())
727        );
728    }
729}