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}