Skip to main content

adk_codeact_monty/
os_access.rs

1//! Host-controlled OS access policy for [`MontyRuntime`](crate::MontyRuntime).
2//!
3//! Monty surfaces every operating-system effect a script attempts — filesystem
4//! reads/writes, `os.getenv`/`os.environ`, and `date.today()`/`datetime.now()` —
5//! as a [`RunProgress::OsCall`](adk_code::embedded_python::monty::RunProgress::OsCall)
6//! the host must resolve. These calls are **not** tools: they never pause the agent loop and
7//! never surface as a [`RunStep::Call`](adk_agent::codeact::RunStep). The
8//! runtime services them in place, bounded by the policy described here, and
9//! resumes the interpreter immediately.
10//!
11//! [`OsAccess`] is that policy:
12//!
13//! - **Filesystem.** Only the directories explicitly mounted with
14//!   [`OsAccessBuilder::allow_path`] are reachable, each as read-only or
15//!   read-write. A script reaches them through `pathlib.Path` against the
16//!   *virtual* mount path. Monty's [`MountTable`] enforces the boundary
17//!   (canonicalization + symlink-escape detection), so a script can never touch
18//!   a host path outside a mount. Any access outside every mount raises
19//!   `PermissionError` (existence checks return `False`, matching CPython).
20//! - **Environment.** `os.getenv(name)` and `os.environ` read the explicit
21//!   string map supplied with [`OsAccessBuilder::environ`]. The map is empty by
22//!   default, so by default the process environment (and any secrets in it) is
23//!   never exposed.
24//! - **Clock.** `date.today()` and `datetime.now()` read the host clock when
25//!   [`OsAccessBuilder::system_clock`] is enabled (the default), and otherwise
26//!   raise a catchable `OSError`.
27//!
28//! Network and subprocess access have no Monty OS-call surface at all, so they
29//! remain unavailable regardless of policy.
30//!
31//! Call servicing itself (environment lookup, clock reads, the mount-table
32//! fallback semantics) is shared with the `adk-code` Monty executors through
33//! [`adk_code::embedded_python::resolve_os_call`], so the two integrations
34//! cannot drift.
35
36use 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/// One host directory mounted at a virtual path, with an access mode.
48#[derive(Debug, Clone)]
49struct MountSpec {
50    /// The absolute virtual path a script uses (e.g. `/data`).
51    virtual_path: String,
52    /// The real host directory backing it.
53    host_path: PathBuf,
54    /// Whether the script may write through this mount.
55    access: PathAccess,
56}
57
58/// The OS-access policy a [`MontyRuntime`](crate::MontyRuntime) enforces while
59/// driving a script.
60///
61/// Build one with [`OsAccess::builder`]; the default
62/// ([`OsAccess::sandboxed`]) grants no filesystem access and an empty
63/// environment, but keeps the host clock available.
64#[derive(Debug, Clone)]
65pub struct OsAccess {
66    mounts: Vec<MountSpec>,
67    environ: BTreeMap<String, String>,
68    system_clock: bool,
69}
70
71impl OsAccess {
72    /// A fully sandboxed policy: no filesystem access, an empty environment, and
73    /// the host clock still available (`date.today()` / `datetime.now()`).
74    #[must_use]
75    pub fn sandboxed() -> Self {
76        Self { mounts: Vec::new(), environ: BTreeMap::new(), system_clock: true }
77    }
78
79    /// Start building a policy.
80    #[must_use]
81    pub fn builder() -> OsAccessBuilder {
82        OsAccessBuilder::new()
83    }
84
85    /// Recover a builder seeded with this policy's settings, for further tweaks.
86    #[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    /// `true` when nothing beyond the (optional) clock is exposed: no mounts and
96    /// an empty environment. Used to render the briefing concisely.
97    fn is_filesystem_and_env_sandboxed(&self) -> bool {
98        self.mounts.is_empty() && self.environ.is_empty()
99    }
100
101    /// Assemble a fresh [`MountTable`] for one run.
102    ///
103    /// A table is built per advance rather than shared, so concurrent runs of
104    /// the same runtime never contend on mount state.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`RuntimeError::Internal`] if a configured host path does not
109    /// exist or is not a directory — a host misconfiguration, not a script
110    /// error.
111    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    /// Resolve a single OS call against this policy, producing the value (or
127    /// exception) to resume the interpreter with. Delegates to the shared
128    /// servicing kernel in `adk-code`.
129    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    /// Render the OS-access section appended to the system prompt, describing
138    /// exactly what the script may touch.
139    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/// Builder for [`OsAccess`].
204#[derive(Debug, Clone)]
205pub struct OsAccessBuilder {
206    mounts: Vec<MountSpec>,
207    environ: BTreeMap<String, String>,
208    system_clock: bool,
209}
210
211impl OsAccessBuilder {
212    /// A builder seeded with the fully sandboxed defaults: no mounts, an empty
213    /// environment, the host clock enabled.
214    #[must_use]
215    pub fn new() -> Self {
216        Self { mounts: Vec::new(), environ: BTreeMap::new(), system_clock: true }
217    }
218
219    /// Make a host directory available to scripts at `virtual_path`.
220    ///
221    /// `virtual_path` is the absolute path a script uses (e.g. `/data`);
222    /// `host_path` is the real directory it maps to. The mount boundary is
223    /// enforced by Monty — a script can never escape it. Call this repeatedly to
224    /// expose several directories.
225    ///
226    /// # Example
227    ///
228    /// ```no_run
229    /// use adk_codeact_monty::{OsAccess, PathAccess};
230    ///
231    /// let access = OsAccess::builder()
232    ///     .allow_path("/data", "/srv/agent/data", PathAccess::ReadOnly)
233    ///     .allow_path("/out", "/srv/agent/out", PathAccess::ReadWrite)
234    ///     .build();
235    /// # let _ = access;
236    /// ```
237    #[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    /// Replace the environment map exposed via `os.getenv` / `os.environ`.
253    ///
254    /// Only the entries provided here are visible to scripts; the host process
255    /// environment is never exposed implicitly.
256    #[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    /// Add or overwrite a single environment variable.
267    #[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    /// Enable or disable host-clock access (`date.today()` / `datetime.now()`).
274    ///
275    /// Enabled by default. Disable it for fully deterministic runs.
276    #[must_use]
277    pub fn system_clock(mut self, enabled: bool) -> Self {
278        self.system_clock = enabled;
279        self
280    }
281
282    /// Finish building the policy.
283    #[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            // The shared servicing kernel raises a catchable OSError for an
364            // ungranted clock.
365            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        // The exact pathlib.Path subset Monty supports must be spelled out so
388        // the model does not reach for unsupported methods.
389        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        // With only an environment configured (no mounts), the pathlib subset is
398        // irrelevant and should not be listed.
399        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}