Skip to main content

harn_vm/stdlib/
process.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::io::Write as _;
4use std::path::PathBuf;
5use std::process::Stdio;
6use std::rc::Rc;
7use std::sync::mpsc;
8use std::time::{Duration, Instant};
9
10use crate::orchestration::RunExecutionRecord;
11use crate::value::{VmError, VmValue};
12use crate::vm::Vm;
13
14const HARN_REPLAY_ENV: &str = "HARN_REPLAY";
15
16thread_local! {
17    pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
18    static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
19}
20
21/// Set the source directory for the current thread (called by VM on file execution).
22pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
23    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(normalize_context_path(dir)));
24}
25
26pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
27    if path.is_absolute() {
28        return path.to_path_buf();
29    }
30    std::env::current_dir()
31        .map(|cwd| cwd.join(path))
32        .unwrap_or_else(|_| path.to_path_buf())
33}
34
35pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
36    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
37}
38
39pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
40    VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
41}
42
43/// Reset thread-local process state (for test isolation).
44pub(crate) fn reset_process_state() {
45    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
46    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
47}
48
49pub fn execution_root_path() -> PathBuf {
50    current_execution_context()
51        .and_then(|context| context.cwd.map(PathBuf::from))
52        .or_else(|| std::env::current_dir().ok())
53        .unwrap_or_else(|| PathBuf::from("."))
54}
55
56pub fn source_root_path() -> PathBuf {
57    VM_SOURCE_DIR
58        .with(|sd| sd.borrow().clone())
59        .or_else(|| {
60            current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
61        })
62        .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
63        .or_else(|| std::env::current_dir().ok())
64        .unwrap_or_else(|| PathBuf::from("."))
65}
66
67pub fn asset_root_path() -> PathBuf {
68    source_root_path()
69}
70
71fn env_override(name: &str) -> Option<String> {
72    (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
73        .then(|| "1".to_string())
74}
75
76pub(crate) fn read_env_value(name: &str) -> Option<String> {
77    env_override(name)
78        .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
79        .or_else(|| std::env::var(name).ok())
80}
81
82pub fn runtime_root_base() -> PathBuf {
83    find_project_root(&execution_root_path())
84        .or_else(|| find_project_root(&source_root_path()))
85        .unwrap_or_else(source_root_path)
86}
87
88/// Lexically collapse `..` components in `path`. Returns `None` if a
89/// `..` would pop a non-Normal component (i.e. the path tries to walk
90/// above its root anchor). This is a pure-string canonicalization that
91/// does NOT hit the filesystem — symlinks are not followed.
92fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
93    use std::path::Component;
94    let mut out: Vec<Component> = Vec::new();
95    for component in path.components() {
96        match component {
97            Component::CurDir => {}
98            Component::ParentDir => {
99                let popped = out.pop();
100                if !matches!(popped, Some(Component::Normal(_))) {
101                    return None;
102                }
103            }
104            other => out.push(other),
105        }
106    }
107    Some(out.iter().collect())
108}
109
110pub fn resolve_source_relative_path(path: &str) -> PathBuf {
111    let candidate = PathBuf::from(path);
112    if candidate.is_absolute() {
113        return candidate;
114    }
115    let root = execution_root_path();
116    let joined = root.join(&candidate);
117    // Defense-in-depth path-traversal check (paired with the deferred
118    // F3 sandbox-by-default fix): refuse to resolve a path that
119    // escapes the project root via `..` components. We anchor against
120    // `runtime_root_base()` (the project root), which is broader than
121    // `execution_root_path()` and lets benign sibling-dir walks like
122    // `read_file("../fixtures/payload.json")` from `tests/` succeed.
123    if path_escapes_project_root(&joined) {
124        return root.join("__harn_rejected_parent_dir_traversal__");
125    }
126    joined
127}
128
129pub fn resolve_source_asset_path(path: &str) -> PathBuf {
130    let candidate = PathBuf::from(path);
131    if candidate.is_absolute() {
132        return candidate;
133    }
134    let root = asset_root_path();
135    let joined = root.join(&candidate);
136    if path_escapes_project_root(&joined) {
137        return root.join("__harn_rejected_parent_dir_traversal__");
138    }
139    joined
140}
141
142/// Returns `true` when `joined` (which may contain raw `..`
143/// components) cannot be lexically collapsed without popping past its
144/// root component — i.e. the relative input had more `..` than the
145/// joined depth allows, escaping the filesystem root.
146///
147/// This is intentionally a narrow check: it doesn't try to enforce
148/// that the path stays inside a logical "project root", because the
149/// project root isn't always reliably resolvable (and benign uses
150/// like `../fixtures/x.json` from a `tests/` subdir are legitimate).
151/// The sandbox layer remains the authoritative defense for arbitrary
152/// `..` traversal; this guard plugs the most egregious escapes
153/// (`../../../../etc/passwd`) for the no-sandbox-by-default
154/// `harn run` path.
155fn path_escapes_project_root(joined: &std::path::Path) -> bool {
156    lexically_collapse(joined).is_none()
157}
158
159pub(crate) fn register_process_builtins(vm: &mut Vm) {
160    vm.register_builtin("env", |args, _out| {
161        let name = args.first().map(|a| a.display()).unwrap_or_default();
162        if let Some(value) = read_env_value(&name) {
163            return Ok(VmValue::String(Rc::from(value)));
164        }
165        Ok(VmValue::Nil)
166    });
167
168    vm.register_builtin("env_or", |args, _out| {
169        let name = args.first().map(|a| a.display()).unwrap_or_default();
170        let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
171        if let Some(value) = read_env_value(&name) {
172            return Ok(VmValue::String(Rc::from(value)));
173        }
174        Ok(default)
175    });
176
177    // `timestamp` / `elapsed` are now registered by clock.rs so they
178    // honor mock_time / advance_time. Don't register here.
179
180    vm.register_builtin("exit", |args, _out| {
181        let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
182        std::process::exit(code as i32);
183    });
184
185    vm.register_builtin("exec", |args, _out| {
186        if args.is_empty() {
187            return Err(VmError::Thrown(VmValue::String(Rc::from(
188                "exec: command is required",
189            ))));
190        }
191        let cmd = args[0].display();
192        let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
193        let output = exec_command(None, &cmd, &cmd_args)?;
194        Ok(vm_output_to_value(output))
195    });
196
197    vm.register_builtin("shell", |args, _out| {
198        let cmd = args.first().map(|a| a.display()).unwrap_or_default();
199        if cmd.is_empty() {
200            return Err(VmError::Thrown(VmValue::String(Rc::from(
201                "shell: command string is required",
202            ))));
203        }
204        let invocation = crate::shells::default_shell_invocation(&cmd)
205            .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
206        let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
207        Ok(vm_output_to_value(output))
208    });
209
210    vm.register_builtin("exec_at", |args, _out| {
211        if args.len() < 2 {
212            return Err(VmError::Thrown(VmValue::String(Rc::from(
213                "exec_at: directory and command are required",
214            ))));
215        }
216        let dir = args[0].display();
217        let cmd = args[1].display();
218        let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
219        let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
220        Ok(vm_output_to_value(output))
221    });
222
223    vm.register_builtin("shell_at", |args, _out| {
224        if args.len() < 2 {
225            return Err(VmError::Thrown(VmValue::String(Rc::from(
226                "shell_at: directory and command string are required",
227            ))));
228        }
229        let dir = args[0].display();
230        let cmd = args[1].display();
231        if cmd.is_empty() {
232            return Err(VmError::Thrown(VmValue::String(Rc::from(
233                "shell_at: command string is required",
234            ))));
235        }
236        let invocation = crate::shells::default_shell_invocation(&cmd)
237            .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
238        let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
239        Ok(vm_output_to_value(output))
240    });
241
242    // `elapsed` registered by clock.rs (mockable). See note above.
243
244    vm.register_builtin("username", |_args, _out| {
245        let user = std::env::var("USER")
246            .or_else(|_| std::env::var("USERNAME"))
247            .unwrap_or_default();
248        Ok(VmValue::String(Rc::from(user)))
249    });
250
251    vm.register_builtin("hostname", |_args, _out| {
252        let name = std::env::var("HOSTNAME")
253            .or_else(|_| std::env::var("COMPUTERNAME"))
254            .or_else(|_| {
255                std::process::Command::new("hostname")
256                    .output()
257                    .ok()
258                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
259                    .ok_or(std::env::VarError::NotPresent)
260            })
261            .unwrap_or_default();
262        Ok(VmValue::String(Rc::from(name)))
263    });
264
265    vm.register_builtin("platform", |_args, _out| {
266        let os = if cfg!(target_os = "macos") {
267            "darwin"
268        } else if cfg!(target_os = "linux") {
269            "linux"
270        } else if cfg!(target_os = "windows") {
271            "windows"
272        } else {
273            std::env::consts::OS
274        };
275        Ok(VmValue::String(Rc::from(os)))
276    });
277
278    vm.register_builtin("arch", |_args, _out| {
279        Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
280    });
281
282    vm.register_builtin("home_dir", |_args, _out| {
283        let home = std::env::var("HOME")
284            .or_else(|_| std::env::var("USERPROFILE"))
285            .unwrap_or_default();
286        Ok(VmValue::String(Rc::from(home)))
287    });
288
289    vm.register_builtin("pid", |_args, _out| {
290        Ok(VmValue::Int(std::process::id() as i64))
291    });
292
293    vm.register_builtin("date_iso", |_args, _out| {
294        // `date_iso` reads the OS wall clock directly (it predates the
295        // unified `clock_mock`). Routing through `leak_audit::wall_now`
296        // keeps the production behavior unchanged but surfaces the call
297        // in `testbench_clock_leaks()` whenever a script invokes it
298        // under a paused testbench session, so fidelity hazards are
299        // visible instead of silently corrupting tapes.
300        let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
301        let dt: chrono::DateTime<chrono::Utc> = now.into();
302        Ok(VmValue::String(Rc::from(
303            dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
304        )))
305    });
306
307    vm.register_builtin("cwd", |_args, _out| {
308        let dir = current_execution_context()
309            .and_then(|context| context.cwd)
310            .or_else(|| {
311                std::env::current_dir()
312                    .ok()
313                    .map(|p| p.to_string_lossy().into_owned())
314            })
315            .unwrap_or_default();
316        Ok(VmValue::String(Rc::from(dir)))
317    });
318
319    vm.register_builtin("execution_root", |_args, _out| {
320        Ok(VmValue::String(Rc::from(
321            execution_root_path().to_string_lossy().into_owned(),
322        )))
323    });
324
325    vm.register_builtin("asset_root", |_args, _out| {
326        Ok(VmValue::String(Rc::from(
327            asset_root_path().to_string_lossy().into_owned(),
328        )))
329    });
330
331    vm.register_builtin("runtime_paths", |_args, _out| {
332        let runtime_base = runtime_root_base();
333        let mut paths = BTreeMap::new();
334        paths.insert(
335            "execution_root".to_string(),
336            VmValue::String(Rc::from(
337                execution_root_path().to_string_lossy().into_owned(),
338            )),
339        );
340        paths.insert(
341            "asset_root".to_string(),
342            VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
343        );
344        paths.insert(
345            "state_root".to_string(),
346            VmValue::String(Rc::from(
347                crate::runtime_paths::state_root(&runtime_base)
348                    .to_string_lossy()
349                    .into_owned(),
350            )),
351        );
352        paths.insert(
353            "run_root".to_string(),
354            VmValue::String(Rc::from(
355                crate::runtime_paths::run_root(&runtime_base)
356                    .to_string_lossy()
357                    .into_owned(),
358            )),
359        );
360        paths.insert(
361            "worktree_root".to_string(),
362            VmValue::String(Rc::from(
363                crate::runtime_paths::worktree_root(&runtime_base)
364                    .to_string_lossy()
365                    .into_owned(),
366            )),
367        );
368        Ok(VmValue::Dict(Rc::new(paths)))
369    });
370
371    vm.register_builtin("spawn_captured", |args, _out| spawn_captured_value(args));
372
373    // `term_width()` / `term_height()` return the current terminal
374    // dimensions in columns and rows. Reads `COLUMNS` / `LINES` env vars
375    // first (so test harnesses can pin a value), falls back to the
376    // platform `ioctl` size, and finally defaults to 80x24 when neither
377    // is available (e.g. when stdout is not a TTY). These are the
378    // free-builtin aliases for `harness.term.width()` /
379    // `harness.term.height()`. `std/tui` already exposes
380    // `__tui_terminal_width` for its renderer; these aliases keep
381    // ported subcommands working without importing the tui module.
382    vm.register_builtin("term_width", |_args, _out| {
383        Ok(VmValue::Int(crate::term::width() as i64))
384    });
385    vm.register_builtin("term_height", |_args, _out| {
386        Ok(VmValue::Int(crate::term::height() as i64))
387    });
388}
389
390/// Run an external command synchronously and return captured output.
391///
392/// Shared by the legacy free builtin and `harness.process.spawn_captured` so
393/// subprocess capture has one implementation and one result shape.
394pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
395    let opts = match args.first() {
396        Some(VmValue::Dict(opts)) => opts.clone(),
397        _ => {
398            return Err(VmError::Runtime(
399                "spawn_captured: options dict is required".to_string(),
400            ));
401        }
402    };
403    let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
404        s if s.is_empty() => {
405            return Err(VmError::Runtime(
406                "spawn_captured: opts.cmd is required".to_string(),
407            ));
408        }
409        s => s,
410    };
411    let cmd_args: Vec<String> = match opts.get("args") {
412        Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
413        None | Some(VmValue::Nil) => Vec::new(),
414        Some(other) => {
415            return Err(VmError::Runtime(format!(
416                "spawn_captured: opts.args must be a list of strings, got {}",
417                other.type_name()
418            )));
419        }
420    };
421    let cwd = opts
422        .get("cwd")
423        .map(|v| v.display())
424        .filter(|s| !s.is_empty());
425    let env_overrides: Vec<(String, String)> = match opts.get("env") {
426        Some(VmValue::Dict(env)) => env.iter().map(|(k, v)| (k.clone(), v.display())).collect(),
427        None | Some(VmValue::Nil) => Vec::new(),
428        Some(other) => {
429            return Err(VmError::Runtime(format!(
430                "spawn_captured: opts.env must be a dict, got {}",
431                other.type_name()
432            )));
433        }
434    };
435    let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
436        Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
437        Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
438        None | Some(VmValue::Nil) => None,
439        Some(other) => {
440            return Err(VmError::Runtime(format!(
441                "spawn_captured: opts.stdin must be string or bytes, got {}",
442                other.type_name()
443            )));
444        }
445    };
446    let timeout = opts
447        .get("timeout_ms")
448        .and_then(|v| v.as_int())
449        .filter(|n| *n > 0)
450        .map(|n| Duration::from_millis(n as u64));
451
452    let mut command = std::process::Command::new(&cmd);
453    command.args(&cmd_args);
454    if let Some(cwd) = cwd.as_ref() {
455        command.current_dir(cwd);
456    }
457    for (key, value) in &env_overrides {
458        command.env(key, value);
459    }
460    command.stdout(Stdio::piped()).stderr(Stdio::piped());
461    if stdin_bytes.is_some() {
462        command.stdin(Stdio::piped());
463    } else {
464        command.stdin(Stdio::null());
465    }
466
467    let started = Instant::now();
468    let mut child = command.spawn().map_err(|error| {
469        VmError::Thrown(VmValue::String(Rc::from(format!(
470            "spawn_captured: failed to spawn '{cmd}': {error}"
471        ))))
472    })?;
473
474    if let (Some(payload), Some(mut stdin)) = (stdin_bytes, child.stdin.take()) {
475        // Children may close stdin early while still producing useful output.
476        let _ = stdin.write_all(&payload);
477    }
478
479    let (output, timed_out) = match timeout {
480        None => match child.wait_with_output() {
481            Ok(output) => (output, false),
482            Err(error) => {
483                return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
484                    "spawn_captured: wait failed: {error}"
485                )))));
486            }
487        },
488        Some(limit) => {
489            let deadline = started + limit;
490            let mut timed_out = false;
491            loop {
492                match child.try_wait() {
493                    Ok(Some(_)) => break,
494                    Ok(None) => {
495                        if Instant::now() >= deadline {
496                            let _ = child.kill();
497                            let _ = child.wait();
498                            timed_out = true;
499                            break;
500                        }
501                        std::thread::sleep(Duration::from_millis(10));
502                    }
503                    Err(error) => {
504                        return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
505                            "spawn_captured: poll failed: {error}"
506                        )))));
507                    }
508                }
509            }
510            if timed_out {
511                let stdout_handle = child.stdout.take();
512                let stderr_handle = child.stderr.take();
513                let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();
514                let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();
515                if let Some(mut s) = stdout_handle {
516                    std::thread::spawn(move || {
517                        use std::io::Read as _;
518                        let mut buf = Vec::new();
519                        let _ = s.read_to_end(&mut buf);
520                        let _ = tx_out.send(buf);
521                    });
522                }
523                if let Some(mut s) = stderr_handle {
524                    std::thread::spawn(move || {
525                        use std::io::Read as _;
526                        let mut buf = Vec::new();
527                        let _ = s.read_to_end(&mut buf);
528                        let _ = tx_err.send(buf);
529                    });
530                }
531                let stdout = rx_out
532                    .recv_timeout(Duration::from_millis(100))
533                    .unwrap_or_default();
534                let stderr = rx_err
535                    .recv_timeout(Duration::from_millis(100))
536                    .unwrap_or_default();
537                (
538                    std::process::Output {
539                        status: std::process::ExitStatus::default(),
540                        stdout,
541                        stderr,
542                    },
543                    true,
544                )
545            } else {
546                match child.wait_with_output() {
547                    Ok(output) => (output, false),
548                    Err(error) => {
549                        return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
550                            "spawn_captured: wait failed: {error}"
551                        )))));
552                    }
553                }
554            }
555        }
556    };
557
558    let duration_ms = started.elapsed().as_millis() as i64;
559    let exit_code = if timed_out {
560        -1
561    } else {
562        output.status.code().unwrap_or(-1) as i64
563    };
564    let success = if timed_out {
565        false
566    } else {
567        output.status.success()
568    };
569    let mut result = BTreeMap::new();
570    result.insert("exit_code".to_string(), VmValue::Int(exit_code));
571    result.insert(
572        "stdout".to_string(),
573        VmValue::String(Rc::from(String::from_utf8_lossy(&output.stdout).as_ref())),
574    );
575    result.insert(
576        "stderr".to_string(),
577        VmValue::String(Rc::from(String::from_utf8_lossy(&output.stderr).as_ref())),
578    );
579    result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
580    result.insert("success".to_string(), VmValue::Bool(success));
581    result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
582    Ok(VmValue::Dict(Rc::new(result)))
583}
584
585/// Find the project root by walking up from a base directory looking for harn.toml.
586pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
587    let mut dir = base.to_path_buf();
588    loop {
589        if dir.join("harn.toml").exists() {
590            return Some(dir);
591        }
592        if !dir.pop() {
593            return None;
594        }
595    }
596}
597
598/// Register builtins that depend on source directory context.
599pub(crate) fn register_path_builtins(vm: &mut Vm) {
600    vm.register_builtin("source_dir", |_args, _out| {
601        let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
602        match dir {
603            Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
604            None => {
605                let cwd = std::env::current_dir()
606                    .map(|p| p.to_string_lossy().into_owned())
607                    .unwrap_or_default();
608                Ok(VmValue::String(Rc::from(cwd)))
609            }
610        }
611    });
612
613    vm.register_builtin("project_root", |_args, _out| {
614        let base = current_execution_context()
615            .and_then(|context| context.cwd.map(PathBuf::from))
616            .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
617            .or_else(|| std::env::current_dir().ok())
618            .unwrap_or_else(|| PathBuf::from("."));
619        match find_project_root(&base) {
620            Some(root) => Ok(VmValue::String(Rc::from(
621                root.to_string_lossy().into_owned(),
622            ))),
623            None => Ok(VmValue::Nil),
624        }
625    });
626}
627
628fn vm_output_to_value(output: std::process::Output) -> VmValue {
629    let mut result = BTreeMap::new();
630    result.insert(
631        "stdout".to_string(),
632        VmValue::String(Rc::from(String::from_utf8_lossy(&output.stdout).as_ref())),
633    );
634    result.insert(
635        "stderr".to_string(),
636        VmValue::String(Rc::from(String::from_utf8_lossy(&output.stderr).as_ref())),
637    );
638    result.insert(
639        "status".to_string(),
640        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
641    );
642    result.insert(
643        "success".to_string(),
644        VmValue::Bool(output.status.success()),
645    );
646    VmValue::Dict(Rc::new(result))
647}
648
649fn exec_command(
650    dir: Option<&str>,
651    cmd: &str,
652    args: &[String],
653) -> Result<std::process::Output, VmError> {
654    let config = process_command_config(dir)?;
655    crate::stdlib::sandbox::command_output(cmd, args, &config)
656        .map_err(|error| prefix_process_error(error, "exec"))
657}
658
659#[cfg(test)]
660fn exec_shell(
661    dir: Option<&str>,
662    shell: &str,
663    flag: &str,
664    script: &str,
665) -> Result<std::process::Output, VmError> {
666    let args = vec![flag.to_string(), script.to_string()];
667    exec_shell_args(dir, shell, &args)
668}
669
670fn exec_shell_args(
671    dir: Option<&str>,
672    shell: &str,
673    args: &[String],
674) -> Result<std::process::Output, VmError> {
675    let config = process_command_config(dir)?;
676    crate::stdlib::sandbox::command_output(shell, args, &config)
677        .map_err(|error| prefix_process_error(error, "shell"))
678}
679
680fn process_command_config(
681    dir: Option<&str>,
682) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
683    let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
684        stdin_null: true,
685        ..Default::default()
686    };
687    if let Some(dir) = dir {
688        let resolved = resolve_command_dir(dir);
689        crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
690        config.cwd = Some(resolved);
691    } else if let Some(context) = current_execution_context() {
692        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
693            crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
694            config.cwd = Some(std::path::PathBuf::from(cwd));
695        }
696        if !context.env.is_empty() {
697            config.env.extend(context.env);
698        }
699    }
700    if let Some(value) = env_override(HARN_REPLAY_ENV) {
701        config.env.push((HARN_REPLAY_ENV.to_string(), value));
702    }
703    Ok(config)
704}
705
706fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
707    match error {
708        VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(Rc::from(
709            format!("{prefix} failed: {message}"),
710        ))),
711        other => other,
712    }
713}
714
715fn resolve_command_dir(dir: &str) -> PathBuf {
716    let candidate = PathBuf::from(dir);
717    if candidate.is_absolute() {
718        return candidate;
719    }
720    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
721        return PathBuf::from(cwd).join(candidate);
722    }
723    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
724        return source_dir.join(candidate);
725    }
726    candidate
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn lexically_collapse_resolves_sibling_walk() {
735        let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
736        let collapsed = lexically_collapse(&path).expect("sibling walk");
737        assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
738    }
739
740    #[test]
741    fn lexically_collapse_blocks_escape_past_root() {
742        // `/app/../etc/passwd` would lexically resolve to `/etc/passwd`,
743        // but the pop hits a RootDir which is not Normal — refuse.
744        let path = PathBuf::from("/app/../../etc/passwd");
745        assert!(lexically_collapse(&path).is_none());
746    }
747
748    #[test]
749    fn lexically_collapse_strips_curdir() {
750        let path = PathBuf::from("/app/./logs/today.txt");
751        let collapsed = lexically_collapse(&path).expect("curdir is benign");
752        assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
753    }
754
755    #[test]
756    fn resolve_source_relative_path_blocks_obvious_escape() {
757        let dir =
758            std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
759        std::fs::create_dir_all(&dir).unwrap();
760        set_thread_source_dir(&dir);
761        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
762            cwd: Some(dir.to_string_lossy().into_owned()),
763            source_dir: Some(dir.to_string_lossy().into_owned()),
764            env: BTreeMap::new(),
765            adapter: None,
766            repo_path: None,
767            worktree_path: None,
768            branch: None,
769            base_ref: None,
770            cleanup: None,
771        }));
772        // A long string of `..` should escape the temp-root and trip
773        // the rejection sentinel, so the file read fails NotFound
774        // instead of escaping to a different filesystem location.
775        let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
776        assert!(
777            resolved
778                .to_string_lossy()
779                .contains("__harn_rejected_parent_dir_traversal__"),
780            "expected rejection sentinel, got {resolved:?}"
781        );
782        reset_process_state();
783        let _ = std::fs::remove_dir_all(&dir);
784    }
785
786    #[test]
787    fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
788        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
789        std::fs::create_dir_all(&dir).unwrap();
790        let current_dir = std::env::current_dir().unwrap();
791        set_thread_source_dir(&dir);
792        let resolved = resolve_source_relative_path("templates/prompt.txt");
793        assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
794        reset_process_state();
795        let _ = std::fs::remove_dir_all(&dir);
796    }
797
798    #[test]
799    fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
800        let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
801        let source_dir =
802            std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
803        std::fs::create_dir_all(&cwd).unwrap();
804        std::fs::create_dir_all(&source_dir).unwrap();
805        set_thread_source_dir(&source_dir);
806        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
807            cwd: Some(cwd.to_string_lossy().into_owned()),
808            source_dir: Some(source_dir.to_string_lossy().into_owned()),
809            env: BTreeMap::new(),
810            adapter: None,
811            repo_path: None,
812            worktree_path: None,
813            branch: None,
814            base_ref: None,
815            cleanup: None,
816        }));
817        let resolved = resolve_source_relative_path("templates/prompt.txt");
818        assert_eq!(resolved, cwd.join("templates/prompt.txt"));
819        reset_process_state();
820        let _ = std::fs::remove_dir_all(&cwd);
821        let _ = std::fs::remove_dir_all(&source_dir);
822    }
823
824    #[test]
825    fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
826        let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
827        let source_dir =
828            std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
829        std::fs::create_dir_all(&cwd).unwrap();
830        std::fs::create_dir_all(&source_dir).unwrap();
831        set_thread_source_dir(&source_dir);
832        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
833            cwd: Some(cwd.to_string_lossy().into_owned()),
834            source_dir: Some(source_dir.to_string_lossy().into_owned()),
835            env: BTreeMap::new(),
836            adapter: None,
837            repo_path: None,
838            worktree_path: None,
839            branch: None,
840            base_ref: None,
841            cleanup: None,
842        }));
843        let resolved = resolve_source_asset_path("templates/prompt.txt");
844        assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
845        reset_process_state();
846        let _ = std::fs::remove_dir_all(&cwd);
847        let _ = std::fs::remove_dir_all(&source_dir);
848    }
849
850    #[test]
851    fn set_thread_source_dir_absolutizes_relative_paths() {
852        reset_process_state();
853        let current_dir = std::env::current_dir().unwrap();
854        set_thread_source_dir(std::path::Path::new("scripts"));
855        assert_eq!(source_root_path(), current_dir.join("scripts"));
856        reset_process_state();
857    }
858
859    #[test]
860    fn exec_context_sets_default_cwd_and_env() {
861        let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
862        std::fs::create_dir_all(&dir).unwrap();
863        std::fs::write(dir.join("marker.txt"), "ok").unwrap();
864        set_thread_execution_context(Some(RunExecutionRecord {
865            cwd: Some(dir.to_string_lossy().into_owned()),
866            env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
867            ..Default::default()
868        }));
869        let output = exec_shell(
870            None,
871            "sh",
872            "-c",
873            "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
874        )
875        .unwrap();
876        assert!(output.status.success());
877        assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
878        reset_process_state();
879        let _ = std::fs::remove_dir_all(&dir);
880    }
881
882    #[test]
883    fn exec_at_resolves_relative_to_execution_cwd() {
884        let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
885        std::fs::create_dir_all(dir.join("nested")).unwrap();
886        std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
887        set_thread_execution_context(Some(RunExecutionRecord {
888            cwd: Some(dir.to_string_lossy().into_owned()),
889            ..Default::default()
890        }));
891        let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
892        assert!(output.status.success());
893        reset_process_state();
894        let _ = std::fs::remove_dir_all(&dir);
895    }
896
897    #[test]
898    fn runtime_paths_uses_configurable_state_roots() {
899        let base =
900            std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
901        std::fs::create_dir_all(&base).unwrap();
902        std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
903        std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
904        std::env::set_var(
905            crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
906            ".custom-worktrees",
907        );
908        set_thread_execution_context(Some(RunExecutionRecord {
909            cwd: Some(base.to_string_lossy().into_owned()),
910            ..Default::default()
911        }));
912
913        let mut vm = crate::vm::Vm::new();
914        register_process_builtins(&mut vm);
915        let mut out = String::new();
916        let builtin = vm
917            .builtins
918            .get("runtime_paths")
919            .expect("runtime_paths builtin");
920        let paths = match builtin(&[], &mut out).unwrap() {
921            VmValue::Dict(map) => map,
922            other => panic!("expected dict, got {other:?}"),
923        };
924        assert_eq!(
925            paths.get("state_root").unwrap().display(),
926            base.join(".custom-harn").display().to_string()
927        );
928        assert_eq!(
929            paths.get("run_root").unwrap().display(),
930            base.join(".custom-runs").display().to_string()
931        );
932        assert_eq!(
933            paths.get("worktree_root").unwrap().display(),
934            base.join(".custom-worktrees").display().to_string()
935        );
936
937        reset_process_state();
938        std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
939        std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
940        std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
941        let _ = std::fs::remove_dir_all(&base);
942    }
943}