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}