1use 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#[derive(Clone, Debug)]
31pub struct LocalSandboxConfig {
32 pub root_dir: Option<PathBuf>,
35 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#[derive(Clone, Debug)]
52pub struct LocalSandbox {
53 config: LocalSandboxConfig,
54 sessions: Arc<Mutex<HashMap<SandboxSessionId, Arc<LocalSession>>>>,
55}
56
57impl LocalSandbox {
58 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 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 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 #[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 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}