Skip to main content

locode_host/
lib.rs

1//! locode-host — the one place the system touches the OS (ADR-0008).
2//!
3//! Every side effect funnels through the [`Host`]: a path jail, shell execution with a
4//! timeout + output cap, jailed filesystem helpers, and the shared
5//! post-processes. Tools never call `std::fs`/`Command` directly —
6//! they go through a `Host`. This crate is a *sibling* of `locode-tools`, so it defines
7//! its own plain error types; the harness pack (which depends on both) maps them to
8//! `ToolError::Respond`.
9
10mod fs;
11mod path;
12mod rg;
13mod shell;
14
15pub use fs::{DirEntry, FileRead, FileStat, FsError};
16pub use path::PathError;
17pub use rg::rg_program;
18pub use shell::{ExecError, ExecOutput, ExecRequest};
19
20use std::path::{Path, PathBuf};
21use std::time::Duration;
22
23/// Whether the filesystem tools are jailed to the workspace root (ADR-0008 amendment).
24///
25/// The jail is the **default** posture, not a hard wall — every studied harness offers a
26/// full-access mode, and since we are headless (no permission prompt) a caller must be
27/// able to opt out (`--dangerously-skip-permissions` / `--yolo`). The shell tool is never
28/// path-jailed regardless; only the structured FS tools consult this.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum PathPolicy {
31    /// Resolve FS-tool paths under `workspace_root`; reject `..`/absolute/symlink escapes.
32    #[default]
33    Jailed,
34    /// Resolve relative paths against `cwd`, allow absolute / out-of-root paths (the
35    /// `--dangerously-skip-permissions` / `--yolo` behavior).
36    Unrestricted,
37}
38
39/// Limits on shell execution. Defaults mirror Grok Build (the primary model).
40#[derive(Debug, Clone)]
41pub struct ExecLimits {
42    /// Timeout used when a call passes none (grok/codex default = 10s).
43    pub default_timeout: Duration,
44    /// Hard ceiling; a caller-supplied timeout is clamped to this.
45    pub max_timeout: Duration,
46    /// Max retained output bytes per stream before truncation (grok = `30_000`).
47    pub max_output_bytes: usize,
48    /// Grace between `SIGTERM` and `SIGKILL` when killing a timed-out/cancelled command.
49    pub kill_grace: Duration,
50}
51
52impl Default for ExecLimits {
53    fn default() -> Self {
54        Self {
55            default_timeout: Duration::from_secs(10),
56            max_timeout: Duration::from_mins(10),
57            max_output_bytes: 30_000,
58            kill_grace: Duration::from_secs(2),
59        }
60    }
61}
62
63/// Construction-time configuration for a [`Host`].
64#[derive(Debug, Clone)]
65pub struct HostConfig {
66    /// The path-jail root (canonicalized at [`Host::new`]).
67    pub workspace_root: PathBuf,
68    /// Whether FS tools are jailed to `workspace_root` (default [`PathPolicy::Jailed`]).
69    pub path_policy: PathPolicy,
70    /// Shell execution limits.
71    pub exec: ExecLimits,
72    /// The shell program to run commands through (default `bash`). A configurable field
73    /// so a later pack / shell-detection can override it.
74    pub shell_program: String,
75    /// Run the shell as a login shell (`-lc`, loads the user's profile/RC — matches all
76    /// four studied harnesses) rather than `-c`. Default `true`.
77    pub login_shell: bool,
78}
79
80impl HostConfig {
81    /// A config for `workspace_root` with all defaults (jailed, `bash -lc`, grok limits).
82    pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
83        Self {
84            workspace_root: workspace_root.into(),
85            path_policy: PathPolicy::default(),
86            exec: ExecLimits::default(),
87            shell_program: "bash".to_string(),
88            login_shell: true,
89        }
90    }
91}
92
93/// The injectable side-effect seam (ADR-0008). Construct once per session, share behind
94/// an `Arc`; holds the jail root + policy and the exec/truncation limits.
95#[derive(Debug, Clone)]
96pub struct Host {
97    pub(crate) workspace_root: PathBuf,
98    pub(crate) path_policy: PathPolicy,
99    pub(crate) limits: ExecLimits,
100    pub(crate) shell_program: String,
101    pub(crate) login_shell: bool,
102}
103
104impl Host {
105    /// Build a host, canonicalizing `workspace_root` (the jail root must be a real,
106    /// symlink-resolved absolute path).
107    ///
108    /// # Errors
109    /// [`PathError::InvalidRoot`] if `workspace_root` does not exist or cannot be
110    /// canonicalized.
111    pub fn new(config: HostConfig) -> Result<Self, PathError> {
112        let workspace_root = std::fs::canonicalize(&config.workspace_root).map_err(|e| {
113            PathError::InvalidRoot(format!("{}: {e}", config.workspace_root.display()))
114        })?;
115        Ok(Self {
116            workspace_root,
117            path_policy: config.path_policy,
118            limits: config.exec,
119            shell_program: config.shell_program,
120            login_shell: config.login_shell,
121        })
122    }
123
124    /// The canonicalized jail root.
125    #[must_use]
126    pub fn workspace_root(&self) -> &Path {
127        &self.workspace_root
128    }
129
130    /// The active path policy.
131    #[must_use]
132    pub fn path_policy(&self) -> PathPolicy {
133        self.path_policy
134    }
135
136    /// The shell execution limits.
137    #[must_use]
138    pub fn limits(&self) -> &ExecLimits {
139        &self.limits
140    }
141}
142
143#[cfg(test)]
144pub(crate) fn test_host(root: &Path, policy: PathPolicy, login: bool) -> Host {
145    let mut config = HostConfig::new(root);
146    config.path_policy = policy;
147    config.login_shell = login;
148    Host::new(config).expect("test host")
149}