1use std::collections::BTreeMap;
37use std::path::PathBuf;
38
39use adk_code::embedded_python::monty_fs::MountTable;
40use adk_code::embedded_python::monty_types::{ExtFunctionResult, OsFunctionCall};
41use adk_code::embedded_python::{SUPPORTED_PATH_METHODS, resolve_os_call};
42
43use adk_agent::codeact::RuntimeError;
44
45pub use adk_code::embedded_python::PathAccess;
46
47#[derive(Debug, Clone)]
49struct MountSpec {
50 virtual_path: String,
52 host_path: PathBuf,
54 access: PathAccess,
56}
57
58#[derive(Debug, Clone)]
65pub struct OsAccess {
66 mounts: Vec<MountSpec>,
67 environ: BTreeMap<String, String>,
68 system_clock: bool,
69}
70
71impl OsAccess {
72 #[must_use]
75 pub fn sandboxed() -> Self {
76 Self { mounts: Vec::new(), environ: BTreeMap::new(), system_clock: true }
77 }
78
79 #[must_use]
81 pub fn builder() -> OsAccessBuilder {
82 OsAccessBuilder::new()
83 }
84
85 #[must_use]
87 pub fn into_builder(self) -> OsAccessBuilder {
88 OsAccessBuilder {
89 mounts: self.mounts,
90 environ: self.environ,
91 system_clock: self.system_clock,
92 }
93 }
94
95 fn is_filesystem_and_env_sandboxed(&self) -> bool {
98 self.mounts.is_empty() && self.environ.is_empty()
99 }
100
101 pub(crate) fn build_mount_table(&self) -> Result<MountTable, RuntimeError> {
112 let mut table = MountTable::new();
113 for spec in &self.mounts {
114 table
115 .mount(&spec.virtual_path, &spec.host_path, spec.access.mount_mode(), None)
116 .map_err(|err| {
117 RuntimeError::Internal(format!(
118 "failed to mount {:?} at {:?}: {}",
119 spec.host_path, spec.virtual_path, err
120 ))
121 })?;
122 }
123 Ok(table)
124 }
125
126 pub(crate) fn resolve(
130 &self,
131 call: OsFunctionCall,
132 mounts: &mut MountTable,
133 ) -> ExtFunctionResult {
134 resolve_os_call(call, &self.environ, self.system_clock, mounts)
135 }
136
137 pub(crate) fn prompt_section(&self) -> String {
140 if self.is_filesystem_and_env_sandboxed() {
141 let clock = if self.system_clock {
142 " `date.today()` and `datetime.now()` read the host clock."
143 } else {
144 ""
145 };
146 return format!(
147 "OS access: this is a sandbox. There is no filesystem access (every path is \
148 inaccessible) and `os.environ` is empty. Network and subprocess access are not \
149 available.{clock}"
150 );
151 }
152
153 let mut section = String::from("OS access (sandboxed):\n");
154
155 if self.mounts.is_empty() {
156 section.push_str(
157 "- Filesystem: no paths are accessible; any `pathlib.Path` read/write raises \
158 PermissionError.\n",
159 );
160 } else {
161 section.push_str(
162 "- Filesystem: use `pathlib.Path` against these mounted paths only; every other \
163 path raises PermissionError (existence checks return False):\n",
164 );
165 for spec in &self.mounts {
166 section.push_str(&format!(
167 " - {:?} ({})\n",
168 spec.virtual_path,
169 spec.access.label()
170 ));
171 }
172 section.push_str(SUPPORTED_PATH_METHODS);
173 }
174
175 if self.environ.is_empty() {
176 section.push_str(
177 "- Environment: `os.getenv(name)` returns its default and `os.environ` is empty.\n",
178 );
179 } else {
180 section.push_str(&format!(
181 "- Environment: `os.getenv(name)` and `os.environ` expose {} variable(s).\n",
182 self.environ.len()
183 ));
184 }
185
186 if self.system_clock {
187 section.push_str("- Clock: `date.today()` and `datetime.now()` read the host clock.\n");
188 } else {
189 section.push_str("- Clock: `date.today()` / `datetime.now()` are unavailable.\n");
190 }
191
192 section.push_str("- Network and subprocess access are not available.");
193 section
194 }
195}
196
197impl Default for OsAccess {
198 fn default() -> Self {
199 Self::sandboxed()
200 }
201}
202
203#[derive(Debug, Clone)]
205pub struct OsAccessBuilder {
206 mounts: Vec<MountSpec>,
207 environ: BTreeMap<String, String>,
208 system_clock: bool,
209}
210
211impl OsAccessBuilder {
212 #[must_use]
215 pub fn new() -> Self {
216 Self { mounts: Vec::new(), environ: BTreeMap::new(), system_clock: true }
217 }
218
219 #[must_use]
238 pub fn allow_path(
239 mut self,
240 virtual_path: impl Into<String>,
241 host_path: impl Into<PathBuf>,
242 access: PathAccess,
243 ) -> Self {
244 self.mounts.push(MountSpec {
245 virtual_path: virtual_path.into(),
246 host_path: host_path.into(),
247 access,
248 });
249 self
250 }
251
252 #[must_use]
257 pub fn environ<K, V>(mut self, vars: impl IntoIterator<Item = (K, V)>) -> Self
258 where
259 K: Into<String>,
260 V: Into<String>,
261 {
262 self.environ = vars.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
263 self
264 }
265
266 #[must_use]
268 pub fn environ_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
269 self.environ.insert(key.into(), value.into());
270 self
271 }
272
273 #[must_use]
277 pub fn system_clock(mut self, enabled: bool) -> Self {
278 self.system_clock = enabled;
279 self
280 }
281
282 #[must_use]
284 pub fn build(self) -> OsAccess {
285 OsAccess { mounts: self.mounts, environ: self.environ, system_clock: self.system_clock }
286 }
287}
288
289impl Default for OsAccessBuilder {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use adk_code::embedded_python::monty_types::{ExcType, GetenvArgs, MontyObject};
299
300 #[test]
301 fn getenv_returns_value_or_default() {
302 let access = OsAccess::builder().environ_var("HOME", "/home/agent").build();
303 let mut mounts = access.build_mount_table().unwrap();
304
305 let hit = access.resolve(
306 OsFunctionCall::Getenv(GetenvArgs {
307 key: "HOME".to_string(),
308 default: MontyObject::None,
309 }),
310 &mut mounts,
311 );
312 assert!(
313 matches!(hit, ExtFunctionResult::Return(MontyObject::String(s)) if s == "/home/agent")
314 );
315
316 let miss = access.resolve(
317 OsFunctionCall::Getenv(GetenvArgs {
318 key: "MISSING".to_string(),
319 default: MontyObject::String("fallback".to_string()),
320 }),
321 &mut mounts,
322 );
323 assert!(
324 matches!(miss, ExtFunctionResult::Return(MontyObject::String(s)) if s == "fallback")
325 );
326 }
327
328 #[test]
329 fn environ_projects_the_configured_map() {
330 let access = OsAccess::builder().environ_var("A", "1").environ_var("B", "2").build();
331 let mut mounts = access.build_mount_table().unwrap();
332
333 let ExtFunctionResult::Return(MontyObject::Dict(pairs)) =
334 access.resolve(OsFunctionCall::GetEnviron, &mut mounts)
335 else {
336 panic!("expected a dict from os.environ");
337 };
338 assert_eq!(pairs.len(), 2);
339 }
340
341 #[test]
342 fn unmounted_read_is_a_permission_error_but_existence_is_false() {
343 let access = OsAccess::sandboxed();
344 let mut mounts = access.build_mount_table().unwrap();
345
346 let read = access.resolve(OsFunctionCall::ReadText("/etc/passwd".into()), &mut mounts);
347 match read {
348 ExtFunctionResult::Error(exc) => assert_eq!(exc.exc_type(), ExcType::PermissionError),
349 other => panic!("expected PermissionError, got {other:?}"),
350 }
351
352 let exists = access.resolve(OsFunctionCall::Exists("/etc/passwd".into()), &mut mounts);
353 assert!(matches!(exists, ExtFunctionResult::Return(MontyObject::Bool(false))));
354 }
355
356 #[test]
357 fn disabled_clock_refuses_date_calls() {
358 let access = OsAccess::builder().system_clock(false).build();
359 let mut mounts = access.build_mount_table().unwrap();
360
361 let today = access.resolve(OsFunctionCall::DateToday, &mut mounts);
362 match today {
363 ExtFunctionResult::Error(exc) => assert_eq!(exc.exc_type(), ExcType::OSError),
366 other => panic!("expected a refusal, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn enabled_clock_returns_a_date() {
372 let access = OsAccess::sandboxed();
373 let mut mounts = access.build_mount_table().unwrap();
374 let today = access.resolve(OsFunctionCall::DateToday, &mut mounts);
375 assert!(matches!(today, ExtFunctionResult::Return(MontyObject::Date(_))));
376 }
377
378 #[test]
379 fn prompt_section_lists_mounts_env_and_supported_path_methods() {
380 let section = OsAccess::builder()
381 .allow_path("/data", "/srv/data", PathAccess::ReadOnly)
382 .environ_var("TOKEN", "x")
383 .build()
384 .prompt_section();
385 assert!(section.contains("\"/data\" (read-only)"), "{section}");
386 assert!(section.contains("expose 1 variable"), "{section}");
387 assert!(section.contains("subset of `pathlib.Path`"), "{section}");
390 assert!(section.contains("`read_text()`"), "{section}");
391 assert!(section.contains("`iterdir()`"), "{section}");
392 assert!(section.contains("read-write mounts only"), "{section}");
393 }
394
395 #[test]
396 fn prompt_section_omits_path_methods_when_no_paths_are_mounted() {
397 let section = OsAccess::builder().environ_var("TOKEN", "x").build().prompt_section();
400 assert!(section.contains("no paths are accessible"), "{section}");
401 assert!(!section.contains("subset of `pathlib.Path`"), "{section}");
402 }
403
404 #[test]
405 fn sandboxed_prompt_section_states_no_access() {
406 let section = OsAccess::sandboxed().prompt_section();
407 assert!(section.contains("no filesystem access"), "{section}");
408 assert!(section.contains("host clock"), "{section}");
409 }
410}