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 tokio::io::AsyncWriteExt;
9
10use crate::error::DevToolError;
11
12/// A workspace roots every file/search/shell operation at a directory and
13/// enforces a small capability policy.
14///
15/// All paths supplied to the tools are resolved relative to [`root`](Self::root)
16/// and rejected if they escape it. Mutating operations require
17/// [`is_writable`](Self::is_writable); `bash` requires [`bash_allowed`](Self::bash_allowed).
18///
19/// The workspace also carries a small amount of shared session state — the set
20/// of files that have been read — so that `edit_file` can require a prior
21/// `read_file` (guarding against blind overwrites).
22///
23/// `Workspace` is cheap to clone; clones share the read-tracking state.
24#[derive(Clone)]
25pub struct Workspace {
26    root: PathBuf,
27    writable: bool,
28    allow_bash: bool,
29    bash_timeout: Duration,
30    max_output_bytes: usize,
31    read_tracker: Arc<Mutex<HashSet<PathBuf>>>,
32    /// Whether `bash` inherits the parent process environment.
33    inherit_env: bool,
34    /// Variables passed through when the environment is not inherited.
35    env_allowlist: Vec<String>,
36}
37
38/// Environment variables `bash` receives by default.
39///
40/// The parent environment of an agent process routinely holds provider API keys, and a
41/// model-directed command could read them with `env`. Only variables tools genuinely
42/// need to function are passed through, and none of them is a credential.
43pub const DEFAULT_ENV_ALLOWLIST: &[&str] =
44    &["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"];
45
46impl Workspace {
47    /// Create a read-write workspace rooted at `root` (bash enabled).
48    ///
49    /// If `root` exists it is canonicalized so containment checks are robust.
50    pub fn new(root: impl Into<PathBuf>) -> Self {
51        let root = root.into();
52        let root = std::fs::canonicalize(&root).unwrap_or(root);
53        Self {
54            root,
55            writable: true,
56            allow_bash: true,
57            bash_timeout: Duration::from_secs(120),
58            max_output_bytes: 1_048_576,
59            read_tracker: Arc::new(Mutex::new(HashSet::new())),
60            inherit_env: false,
61            env_allowlist: DEFAULT_ENV_ALLOWLIST.iter().map(|k| (*k).to_string()).collect(),
62        }
63    }
64
65    /// Create a read-only workspace (no writes, no bash) — useful for
66    /// exploration / plan modes.
67    pub fn read_only(root: impl Into<PathBuf>) -> Self {
68        let mut ws = Self::new(root);
69        ws.writable = false;
70        ws.allow_bash = false;
71        ws
72    }
73
74    /// Set whether mutating file operations are permitted.
75    pub fn writable(mut self, yes: bool) -> Self {
76        self.writable = yes;
77        self
78    }
79
80    /// Set whether the `bash` tool is permitted.
81    pub fn allow_bash(mut self, yes: bool) -> Self {
82        self.allow_bash = yes;
83        self
84    }
85
86    /// Set the default timeout applied to `bash` commands.
87    pub fn bash_timeout(mut self, timeout: Duration) -> Self {
88        self.bash_timeout = timeout;
89        self
90    }
91
92    /// Set the maximum number of bytes captured from a stream before truncation.
93    pub fn max_output_bytes(mut self, bytes: usize) -> Self {
94        self.max_output_bytes = bytes;
95        self
96    }
97
98    /// Pass the whole parent environment to `bash`.
99    ///
100    /// Off by default, because the parent environment of an agent process routinely
101    /// holds provider API keys and a model-directed command can read them with `env`.
102    /// Enable it only when the commands you run genuinely need the caller's environment
103    /// and you accept that exposure.
104    #[must_use]
105    pub fn inherit_env(mut self, yes: bool) -> Self {
106        self.inherit_env = yes;
107        self
108    }
109
110    /// Replace the variables `bash` receives when the environment is not inherited.
111    ///
112    /// Defaults to [`DEFAULT_ENV_ALLOWLIST`]. Adding a variable that holds a credential
113    /// re-exposes it.
114    #[must_use]
115    pub fn env_allowlist<I, S>(mut self, keys: I) -> Self
116    where
117        I: IntoIterator<Item = S>,
118        S: Into<String>,
119    {
120        self.env_allowlist = keys.into_iter().map(Into::into).collect();
121        self
122    }
123
124    /// Whether `bash` inherits the parent environment.
125    pub fn inherits_env(&self) -> bool {
126        self.inherit_env
127    }
128
129    /// The variables `bash` receives, resolved from the current process.
130    ///
131    /// Empty when the environment is inherited, in which case the caller must not clear
132    /// it.
133    pub fn bash_env(&self) -> Vec<(String, String)> {
134        if self.inherit_env {
135            return Vec::new();
136        }
137        self.env_allowlist
138            .iter()
139            .filter_map(|key| std::env::var(key).ok().map(|value| (key.clone(), value)))
140            .collect()
141    }
142
143    /// The workspace root.
144    pub fn root(&self) -> &Path {
145        &self.root
146    }
147
148    /// Whether mutating file operations are permitted.
149    pub fn is_writable(&self) -> bool {
150        self.writable
151    }
152
153    /// Whether the `bash` tool is permitted.
154    pub fn bash_allowed(&self) -> bool {
155        self.allow_bash
156    }
157
158    /// The default `bash` timeout.
159    pub fn bash_timeout_value(&self) -> Duration {
160        self.bash_timeout
161    }
162
163    /// The output-capture cap.
164    pub fn max_output(&self) -> usize {
165        self.max_output_bytes
166    }
167
168    /// Resolve a user-supplied path against the root, rejecting any path that
169    /// escapes it (lexically). The target need not exist yet.
170    pub fn resolve(&self, path: &str) -> Result<PathBuf, DevToolError> {
171        let requested = Path::new(path);
172        let joined = if requested.is_absolute() {
173            requested.to_path_buf()
174        } else {
175            self.root.join(requested)
176        };
177        let normalized = normalize(&joined);
178        if !normalized.starts_with(&self.root) {
179            return Err(DevToolError::PathEscape(path.to_string()));
180        }
181        // A lexical check alone is not containment: a symlink sitting lexically
182        // under the root can point anywhere, and ordinary file I/O follows it.
183        self.reject_symlink_escape(&normalized, path)?;
184        Ok(normalized)
185    }
186
187    /// Rejects a path that reaches outside the root by following a symlink.
188    ///
189    /// The deepest existing ancestor of the target is canonicalized, which resolves
190    /// every symlink along the way, and the result must still be inside the root.
191    /// That covers a symlinked final component and a symlinked parent directory, so
192    /// creation through a redirected directory is refused as well. A symlink whose
193    /// target stays inside the workspace is allowed, since repositories legitimately
194    /// contain internal links.
195    ///
196    /// A dangling or looping symlink anywhere on the path is refused, wherever it
197    /// points: canonicalization cannot see through it, and a write follows it and
198    /// creates its target.
199    ///
200    /// This is a check, not a lock. [`write_contained`](Self::write_contained)
201    /// narrows the window for a symlink swapped in before the open.
202    fn reject_symlink_escape(
203        &self,
204        normalized: &Path,
205        requested: &str,
206    ) -> Result<(), DevToolError> {
207        let mut existing = normalized;
208        loop {
209            match std::fs::canonicalize(existing) {
210                Ok(canonical) => {
211                    if !canonical.starts_with(&self.root) {
212                        return Err(DevToolError::PathEscape(requested.to_string()));
213                    }
214                    return Ok(());
215                }
216                Err(_) => {
217                    // An entry that exists but does not canonicalize is a symlink whose
218                    // target is missing or loops back on itself.
219                    if std::fs::symlink_metadata(existing).is_ok() {
220                        return Err(DevToolError::PathEscape(requested.to_string()));
221                    }
222                    // Nothing exists here yet, so step up to what does. A component
223                    // that does not exist cannot redirect anything.
224                    match existing.parent() {
225                        Some(parent) if parent.starts_with(&self.root) => existing = parent,
226                        _ => return Ok(()),
227                    }
228                }
229            }
230        }
231    }
232
233    /// Writes `contents` to a path returned by [`resolve`](Self::resolve), creating
234    /// or truncating the file.
235    ///
236    /// The canonical parent is re-checked against the root immediately before the
237    /// open, and on Unix the final component is opened with `O_NOFOLLOW`, so a
238    /// symlink planted after `resolve` fails the open instead of redirecting the
239    /// write. An existing symlink that `resolve` accepted, which points inside the
240    /// workspace, is written through its canonical target.
241    pub(crate) async fn write_contained(
242        &self,
243        resolved: &Path,
244        contents: &[u8],
245    ) -> Result<(), DevToolError> {
246        let escape = || DevToolError::PathEscape(self.display(resolved));
247        let is_symlink = tokio::fs::symlink_metadata(resolved)
248            .await
249            .is_ok_and(|meta| meta.file_type().is_symlink());
250        let target = if is_symlink {
251            tokio::fs::canonicalize(resolved).await.map_err(|_| escape())?
252        } else {
253            let (Some(parent), Some(name)) = (resolved.parent(), resolved.file_name()) else {
254                return Err(escape());
255            };
256            tokio::fs::canonicalize(parent).await?.join(name)
257        };
258        if !target.starts_with(&self.root) {
259            return Err(escape());
260        }
261
262        let mut options = tokio::fs::OpenOptions::new();
263        options.write(true).create(true).truncate(true);
264        #[cfg(unix)]
265        options.custom_flags(libc::O_NOFOLLOW);
266        let mut file = options.open(&target).await?;
267        file.write_all(contents).await?;
268        file.flush().await?;
269        Ok(())
270    }
271
272    /// Render a path relative to the root for display (falls back to the full path).
273    pub fn display(&self, path: &Path) -> String {
274        path.strip_prefix(&self.root).unwrap_or(path).display().to_string()
275    }
276
277    /// Record that a file has been read this session.
278    pub(crate) fn mark_read(&self, path: &Path) {
279        if let Ok(mut set) = self.read_tracker.lock() {
280            set.insert(path.to_path_buf());
281        }
282    }
283
284    /// Whether a file has been read this session.
285    pub(crate) fn was_read(&self, path: &Path) -> bool {
286        self.read_tracker.lock().map(|set| set.contains(path)).unwrap_or(false)
287    }
288}
289
290/// Lexically normalize a path, resolving `.` and `..` without touching the
291/// filesystem (so non-existent targets still normalize).
292fn normalize(path: &Path) -> PathBuf {
293    let mut out = PathBuf::new();
294    for component in path.components() {
295        match component {
296            Component::ParentDir => {
297                out.pop();
298            }
299            Component::CurDir => {}
300            other => out.push(other.as_os_str()),
301        }
302    }
303    out
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn rejects_escape() {
312        let dir = tempfile::tempdir().unwrap();
313        let ws = Workspace::new(dir.path());
314        assert!(ws.resolve("../etc/passwd").is_err());
315        assert!(ws.resolve("ok/file.rs").is_ok());
316    }
317
318    #[cfg(unix)]
319    #[tokio::test]
320    async fn a_symlink_planted_after_resolve_is_not_followed() {
321        let temp = tempfile::tempdir().unwrap();
322        let root = temp.path().join("workspace");
323        let outside = temp.path().join("outside");
324        std::fs::create_dir_all(&root).unwrap();
325        std::fs::create_dir_all(&outside).unwrap();
326        std::fs::write(outside.join("existing.txt"), "host").unwrap();
327        let ws = Workspace::new(&root);
328
329        for (name, target) in
330            [("dangling", outside.join("planted.txt")), ("existing", outside.join("existing.txt"))]
331        {
332            let resolved = ws.resolve(name).expect("the path does not exist yet");
333            std::os::unix::fs::symlink(&target, root.join(name)).unwrap();
334
335            let result = ws.write_contained(&resolved, b"payload").await;
336
337            assert!(
338                matches!(result, Err(DevToolError::PathEscape(_))),
339                "a {name} link planted after resolve was followed: {result:?}"
340            );
341        }
342        assert!(!outside.join("planted.txt").exists());
343        assert_eq!(std::fs::read_to_string(outside.join("existing.txt")).unwrap(), "host");
344    }
345
346    #[test]
347    fn read_tracking() {
348        let dir = tempfile::tempdir().unwrap();
349        let ws = Workspace::new(dir.path());
350        let p = ws.resolve("a.txt").unwrap();
351        assert!(!ws.was_read(&p));
352        ws.mark_read(&p);
353        assert!(ws.was_read(&p));
354    }
355}