Skip to main content

adk_devtools/
workspace.rs

1//! The sandboxed workspace that scopes every developer-tool operation.
2
3use std::collections::HashSet;
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::error::DevToolError;
9
10/// A workspace roots every file/search/shell operation at a directory and
11/// enforces a small capability policy.
12///
13/// All paths supplied to the tools are resolved relative to [`root`](Self::root)
14/// and rejected if they escape it. Mutating operations require
15/// [`is_writable`](Self::is_writable); `bash` requires [`bash_allowed`](Self::bash_allowed).
16///
17/// The workspace also carries a small amount of shared session state — the set
18/// of files that have been read — so that `edit_file` can require a prior
19/// `read_file` (guarding against blind overwrites).
20///
21/// `Workspace` is cheap to clone; clones share the read-tracking state.
22#[derive(Clone)]
23pub struct Workspace {
24    root: PathBuf,
25    writable: bool,
26    allow_bash: bool,
27    bash_timeout: Duration,
28    max_output_bytes: usize,
29    read_tracker: Arc<Mutex<HashSet<PathBuf>>>,
30    /// Whether `bash` inherits the parent process environment.
31    inherit_env: bool,
32    /// Variables passed through when the environment is not inherited.
33    env_allowlist: Vec<String>,
34}
35
36/// Environment variables `bash` receives by default.
37///
38/// The parent environment of an agent process routinely holds provider API keys, and a
39/// model-directed command could read them with `env`. Only variables tools genuinely
40/// need to function are passed through, and none of them is a credential.
41pub const DEFAULT_ENV_ALLOWLIST: &[&str] =
42    &["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"];
43
44impl Workspace {
45    /// Create a read-write workspace rooted at `root` (bash enabled).
46    ///
47    /// If `root` exists it is canonicalized so containment checks are robust.
48    pub fn new(root: impl Into<PathBuf>) -> Self {
49        let root = root.into();
50        let root = std::fs::canonicalize(&root).unwrap_or(root);
51        Self {
52            root,
53            writable: true,
54            allow_bash: true,
55            bash_timeout: Duration::from_secs(120),
56            max_output_bytes: 1_048_576,
57            read_tracker: Arc::new(Mutex::new(HashSet::new())),
58            inherit_env: false,
59            env_allowlist: DEFAULT_ENV_ALLOWLIST.iter().map(|k| (*k).to_string()).collect(),
60        }
61    }
62
63    /// Create a read-only workspace (no writes, no bash) — useful for
64    /// exploration / plan modes.
65    pub fn read_only(root: impl Into<PathBuf>) -> Self {
66        let mut ws = Self::new(root);
67        ws.writable = false;
68        ws.allow_bash = false;
69        ws
70    }
71
72    /// Set whether mutating file operations are permitted.
73    pub fn writable(mut self, yes: bool) -> Self {
74        self.writable = yes;
75        self
76    }
77
78    /// Set whether the `bash` tool is permitted.
79    pub fn allow_bash(mut self, yes: bool) -> Self {
80        self.allow_bash = yes;
81        self
82    }
83
84    /// Set the default timeout applied to `bash` commands.
85    pub fn bash_timeout(mut self, timeout: Duration) -> Self {
86        self.bash_timeout = timeout;
87        self
88    }
89
90    /// Set the maximum number of bytes captured from a stream before truncation.
91    pub fn max_output_bytes(mut self, bytes: usize) -> Self {
92        self.max_output_bytes = bytes;
93        self
94    }
95
96    /// Pass the whole parent environment to `bash`.
97    ///
98    /// Off by default, because the parent environment of an agent process routinely
99    /// holds provider API keys and a model-directed command can read them with `env`.
100    /// Enable it only when the commands you run genuinely need the caller's environment
101    /// and you accept that exposure.
102    #[must_use]
103    pub fn inherit_env(mut self, yes: bool) -> Self {
104        self.inherit_env = yes;
105        self
106    }
107
108    /// Replace the variables `bash` receives when the environment is not inherited.
109    ///
110    /// Defaults to [`DEFAULT_ENV_ALLOWLIST`]. Adding a variable that holds a credential
111    /// re-exposes it.
112    #[must_use]
113    pub fn env_allowlist<I, S>(mut self, keys: I) -> Self
114    where
115        I: IntoIterator<Item = S>,
116        S: Into<String>,
117    {
118        self.env_allowlist = keys.into_iter().map(Into::into).collect();
119        self
120    }
121
122    /// Whether `bash` inherits the parent environment.
123    pub fn inherits_env(&self) -> bool {
124        self.inherit_env
125    }
126
127    /// The variables `bash` receives, resolved from the current process.
128    ///
129    /// Empty when the environment is inherited, in which case the caller must not clear
130    /// it.
131    pub fn bash_env(&self) -> Vec<(String, String)> {
132        if self.inherit_env {
133            return Vec::new();
134        }
135        self.env_allowlist
136            .iter()
137            .filter_map(|key| std::env::var(key).ok().map(|value| (key.clone(), value)))
138            .collect()
139    }
140
141    /// The workspace root.
142    pub fn root(&self) -> &Path {
143        &self.root
144    }
145
146    /// Whether mutating file operations are permitted.
147    pub fn is_writable(&self) -> bool {
148        self.writable
149    }
150
151    /// Whether the `bash` tool is permitted.
152    pub fn bash_allowed(&self) -> bool {
153        self.allow_bash
154    }
155
156    /// The default `bash` timeout.
157    pub fn bash_timeout_value(&self) -> Duration {
158        self.bash_timeout
159    }
160
161    /// The output-capture cap.
162    pub fn max_output(&self) -> usize {
163        self.max_output_bytes
164    }
165
166    /// Resolve a user-supplied path against the root, rejecting any path that
167    /// escapes it (lexically). The target need not exist yet.
168    pub fn resolve(&self, path: &str) -> Result<PathBuf, DevToolError> {
169        let requested = Path::new(path);
170        let joined = if requested.is_absolute() {
171            requested.to_path_buf()
172        } else {
173            self.root.join(requested)
174        };
175        let normalized = normalize(&joined);
176        if !normalized.starts_with(&self.root) {
177            return Err(DevToolError::PathEscape(path.to_string()));
178        }
179        // A lexical check alone is not containment: a symlink sitting lexically
180        // under the root can point anywhere, and ordinary file I/O follows it.
181        self.reject_symlink_escape(&normalized, path)?;
182        Ok(normalized)
183    }
184
185    /// Rejects a path that reaches outside the root by following a symlink.
186    ///
187    /// The deepest existing ancestor of the target is canonicalized, which resolves
188    /// every symlink along the way, and the result must still be inside the root.
189    /// That covers a symlinked final component and a symlinked parent directory, so
190    /// creation through a redirected directory is refused as well. A symlink whose
191    /// target stays inside the workspace is allowed, since repositories legitimately
192    /// contain internal links.
193    ///
194    /// This is a check, not a lock. A symlink swapped between this check and the
195    /// subsequent open would still be followed; closing that window needs
196    /// descriptor-relative traversal with platform no-follow semantics.
197    fn reject_symlink_escape(
198        &self,
199        normalized: &Path,
200        requested: &str,
201    ) -> Result<(), DevToolError> {
202        let mut existing = normalized;
203        loop {
204            match std::fs::canonicalize(existing) {
205                Ok(canonical) => {
206                    if !canonical.starts_with(&self.root) {
207                        return Err(DevToolError::PathEscape(requested.to_string()));
208                    }
209                    return Ok(());
210                }
211                // The path does not exist yet, so step up to what does. A component
212                // that does not exist cannot redirect anything.
213                Err(_) => match existing.parent() {
214                    Some(parent) if parent.starts_with(&self.root) => existing = parent,
215                    _ => return Ok(()),
216                },
217            }
218        }
219    }
220
221    /// Render a path relative to the root for display (falls back to the full path).
222    pub fn display(&self, path: &Path) -> String {
223        path.strip_prefix(&self.root).unwrap_or(path).display().to_string()
224    }
225
226    /// Record that a file has been read this session.
227    pub(crate) fn mark_read(&self, path: &Path) {
228        if let Ok(mut set) = self.read_tracker.lock() {
229            set.insert(path.to_path_buf());
230        }
231    }
232
233    /// Whether a file has been read this session.
234    pub(crate) fn was_read(&self, path: &Path) -> bool {
235        self.read_tracker.lock().map(|set| set.contains(path)).unwrap_or(false)
236    }
237}
238
239/// Lexically normalize a path, resolving `.` and `..` without touching the
240/// filesystem (so non-existent targets still normalize).
241fn normalize(path: &Path) -> PathBuf {
242    let mut out = PathBuf::new();
243    for component in path.components() {
244        match component {
245            Component::ParentDir => {
246                out.pop();
247            }
248            Component::CurDir => {}
249            other => out.push(other.as_os_str()),
250        }
251    }
252    out
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn rejects_escape() {
261        let dir = tempfile::tempdir().unwrap();
262        let ws = Workspace::new(dir.path());
263        assert!(ws.resolve("../etc/passwd").is_err());
264        assert!(ws.resolve("ok/file.rs").is_ok());
265    }
266
267    #[test]
268    fn read_tracking() {
269        let dir = tempfile::tempdir().unwrap();
270        let ws = Workspace::new(dir.path());
271        let p = ws.resolve("a.txt").unwrap();
272        assert!(!ws.was_read(&p));
273        ws.mark_read(&p);
274        assert!(ws.was_read(&p));
275    }
276}