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 home;
12mod path;
13mod rg;
14mod root;
15mod session_dirs;
16mod settings;
17mod shell;
18mod trace;
19mod walk;
20
21pub use fs::{DirEntry, FileRead, FileStat, FsError};
22pub use home::{default_locode_home, locode_home, resolve_home_from};
23pub use path::PathError;
24pub use rg::rg_program;
25pub use root::find_root_from_markers;
26pub use session_dirs::{decode_cwd_dirname, encode_cwd_dirname};
27pub use settings::{
28    Settings, SettingsLoad, SkillsExtraEntry, load_settings, load_settings_from,
29    update_user_setting,
30};
31pub use shell::{
32    ExecError, ExecOutput, ExecRequest, FrontBackCapture, FrontBackSpec, SPILL_RETAIN_MAX,
33    ShellSpec,
34};
35pub use trace::{
36    GitMeta, RolloutContents, SessionMeta, SessionScope, SessionSummary, TRACE_SCHEMA_VERSION,
37    TraceExtras, TraceWriter, find_latest_rollout, find_rollout_by_id, list_sessions, read_rollout,
38    read_session_title,
39};
40pub use walk::{WalkEntry, WalkOptions};
41
42use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45/// Whether the filesystem tools are jailed to the workspace root (ADR-0008 amendment).
46///
47/// The jail is the **default** posture, not a hard wall — every studied harness offers a
48/// full-access mode, and since we are headless (no permission prompt) a caller must be
49/// able to opt out (`--dangerously-skip-permissions` / `--yolo`). The shell tool is never
50/// path-jailed regardless; only the structured FS tools consult this.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum PathPolicy {
53    /// Resolve FS-tool paths under `workspace_root`; reject `..`/absolute/symlink escapes.
54    #[default]
55    Jailed,
56    /// Resolve relative paths against `cwd`, allow absolute / out-of-root paths (the
57    /// `--dangerously-skip-permissions` / `--yolo` behavior).
58    Unrestricted,
59}
60
61/// Limits on shell execution. Defaults mirror Grok Build (the primary model).
62#[derive(Debug, Clone)]
63pub struct ExecLimits {
64    /// Timeout used when a call passes none (grok/codex default = 10s).
65    pub default_timeout: Duration,
66    /// Hard ceiling; a caller-supplied timeout is clamped to this.
67    pub max_timeout: Duration,
68    /// Max retained output bytes per stream before truncation (grok = `30_000`).
69    pub max_output_bytes: usize,
70    /// Grace between `SIGTERM` and `SIGKILL` when killing a timed-out/cancelled command.
71    pub kill_grace: Duration,
72}
73
74impl Default for ExecLimits {
75    fn default() -> Self {
76        Self {
77            default_timeout: Duration::from_secs(10),
78            max_timeout: Duration::from_mins(10),
79            max_output_bytes: 30_000,
80            kill_grace: Duration::from_secs(2),
81        }
82    }
83}
84
85/// Construction-time configuration for a [`Host`].
86#[derive(Debug, Clone)]
87pub struct HostConfig {
88    /// The path-jail root (canonicalized at [`Host::new`]).
89    pub workspace_root: PathBuf,
90    /// Additional jail roots (`--add-dir`), canonicalized at [`Host::new`].
91    ///
92    /// A jailed path is accepted when it resolves under `workspace_root` **or** any
93    /// of these. Empty by default — the jail stays single-rooted unless a caller
94    /// explicitly widens it (ADR-0008 amendment 2026-07-25).
95    pub extra_roots: Vec<PathBuf>,
96    /// Whether FS tools are jailed to `workspace_root` (default [`PathPolicy::Jailed`]).
97    pub path_policy: PathPolicy,
98    /// Shell execution limits.
99    pub exec: ExecLimits,
100    /// The shell program to run commands through (default `bash`). A configurable field
101    /// so a later pack / shell-detection can override it.
102    pub shell_program: String,
103    /// Run the shell as a login shell (`-lc`, loads the user's profile/RC — matches all
104    /// four studied harnesses) rather than `-c`. Default `true`.
105    pub login_shell: bool,
106}
107
108impl HostConfig {
109    /// A config for `workspace_root` with all defaults (jailed, `bash -lc`, grok limits).
110    pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
111        Self {
112            workspace_root: workspace_root.into(),
113            extra_roots: Vec::new(),
114            path_policy: PathPolicy::default(),
115            exec: ExecLimits::default(),
116            shell_program: "bash".to_string(),
117            login_shell: true,
118        }
119    }
120}
121
122/// The injectable side-effect seam (ADR-0008). Construct once per session, share behind
123/// an `Arc`; holds the jail root + policy and the exec/truncation limits.
124#[derive(Debug, Clone)]
125pub struct Host {
126    pub(crate) workspace_root: PathBuf,
127    /// Extra jail roots. Behind a lock because `/add-dir` widens the jail on a
128    /// **running** session: tools already hold `Arc<Host>`, so the alternative
129    /// is rebuilding the session — which would discard the conversation. Claude
130    /// Code keeps its `additionalWorkingDirectories` mutable for the same
131    /// reason. Shared across clones, so every tool sees one list.
132    pub(crate) extra_roots: std::sync::Arc<std::sync::RwLock<Vec<PathBuf>>>,
133    /// Every jail root in its **as-given** (lexically normalized, not
134    /// symlink-resolved) form. macOS `/var` → `/private/var` is the everyday
135    /// case, and a monorepo reached through a symlinked path is the one that
136    /// matters: without this the lexical pre-check rejects an absolute path the
137    /// canonical check would then have accepted. Claude Code checks the same
138    /// two forms (`permissions/filesystem.ts:688`). The canonical lists above
139    /// remain the authoritative gate.
140    pub(crate) roots_as_given: std::sync::Arc<std::sync::RwLock<Vec<PathBuf>>>,
141    pub(crate) path_policy: PathPolicy,
142    pub(crate) limits: ExecLimits,
143    pub(crate) shell_program: String,
144    pub(crate) login_shell: bool,
145    /// Lazily-built workspace gitignore matcher (Task 26 Slice 0).
146    pub(crate) gitignore: walk::GitignoreCache,
147    /// Once-probed login-shell PATH (Task 26 Slice 0b; grok's PATH probe).
148    pub(crate) login_path: std::sync::Arc<tokio::sync::OnceCell<Option<String>>>,
149}
150
151impl Host {
152    /// Widen the jail with another root at runtime (`/add-dir`).
153    ///
154    /// Returns the canonicalized root on success. Both forms are recorded, for
155    /// the same reason [`Host::new`] records both: a root behind a symlink must
156    /// still satisfy the lexical pre-check. Adding an already-present root is a
157    /// no-op rather than an error, so repeating the command is harmless.
158    ///
159    /// # Errors
160    /// [`PathError::InvalidRoot`] when `dir` does not exist or cannot be
161    /// canonicalized — the jail is left untouched.
162    pub fn add_root(&self, dir: &Path) -> Result<PathBuf, PathError> {
163        let canonical = std::fs::canonicalize(dir)
164            .map_err(|e| PathError::InvalidRoot(format!("{}: {e}", dir.display())))?;
165        let mut roots = self
166            .extra_roots
167            .write()
168            .unwrap_or_else(std::sync::PoisonError::into_inner);
169        if !roots.contains(&canonical) {
170            roots.push(canonical.clone());
171        }
172        drop(roots);
173        let mut as_given = self
174            .roots_as_given
175            .write()
176            .unwrap_or_else(std::sync::PoisonError::into_inner);
177        let lexical = crate::path::normalize_lexical_pub(dir);
178        if !as_given.contains(&lexical) {
179            as_given.push(lexical);
180        }
181        Ok(canonical)
182    }
183
184    /// Build a host, canonicalizing `workspace_root` (the jail root must be a real,
185    /// symlink-resolved absolute path).
186    ///
187    /// # Errors
188    /// [`PathError::InvalidRoot`] if `workspace_root` does not exist or cannot be
189    /// canonicalized.
190    pub fn new(config: HostConfig) -> Result<Self, PathError> {
191        let workspace_root = std::fs::canonicalize(&config.workspace_root).map_err(|e| {
192            PathError::InvalidRoot(format!("{}: {e}", config.workspace_root.display()))
193        })?;
194        // Every extra root must exist too — a typo'd `--add-dir` should fail loudly
195        // at startup, not silently narrow the jail back to one root.
196        let extra_roots = config
197            .extra_roots
198            .iter()
199            .map(|dir| {
200                std::fs::canonicalize(dir)
201                    .map_err(|e| PathError::InvalidRoot(format!("{}: {e}", dir.display())))
202            })
203            .collect::<Result<Vec<_>, _>>()?;
204        let roots_as_given = std::iter::once(&config.workspace_root)
205            .chain(config.extra_roots.iter())
206            .map(|p| crate::path::normalize_lexical_pub(p))
207            .collect();
208        Ok(Self {
209            workspace_root,
210            extra_roots: std::sync::Arc::new(std::sync::RwLock::new(extra_roots)),
211            roots_as_given: std::sync::Arc::new(std::sync::RwLock::new(roots_as_given)),
212            path_policy: config.path_policy,
213            limits: config.exec,
214            shell_program: config.shell_program,
215            login_shell: config.login_shell,
216            gitignore: walk::GitignoreCache::new(),
217            login_path: std::sync::Arc::new(tokio::sync::OnceCell::new()),
218        })
219    }
220
221    /// The canonicalized jail root.
222    #[must_use]
223    pub fn workspace_root(&self) -> &Path {
224        &self.workspace_root
225    }
226
227    /// The active path policy.
228    #[must_use]
229    pub fn path_policy(&self) -> PathPolicy {
230        self.path_policy
231    }
232
233    /// The shell execution limits.
234    #[must_use]
235    pub fn limits(&self) -> &ExecLimits {
236        &self.limits
237    }
238}
239
240#[cfg(test)]
241pub(crate) fn test_host(root: &Path, policy: PathPolicy, login: bool) -> Host {
242    let mut config = HostConfig::new(root);
243    config.path_policy = policy;
244    config.login_shell = login;
245    Host::new(config).expect("test host")
246}