use std::collections::BTreeMap;
use std::path::PathBuf;
use adk_code::embedded_python::monty_fs::MountTable;
use adk_code::embedded_python::monty_types::{ExtFunctionResult, OsFunctionCall};
use adk_code::embedded_python::{SUPPORTED_PATH_METHODS, resolve_os_call};
use adk_agent::codeact::RuntimeError;
pub use adk_code::embedded_python::PathAccess;
#[derive(Debug, Clone)]
struct MountSpec {
virtual_path: String,
host_path: PathBuf,
access: PathAccess,
}
#[derive(Debug, Clone)]
pub struct OsAccess {
mounts: Vec<MountSpec>,
environ: BTreeMap<String, String>,
system_clock: bool,
}
impl OsAccess {
#[must_use]
pub fn sandboxed() -> Self {
Self { mounts: Vec::new(), environ: BTreeMap::new(), system_clock: true }
}
#[must_use]
pub fn builder() -> OsAccessBuilder {
OsAccessBuilder::new()
}
#[must_use]
pub fn into_builder(self) -> OsAccessBuilder {
OsAccessBuilder {
mounts: self.mounts,
environ: self.environ,
system_clock: self.system_clock,
}
}
fn is_filesystem_and_env_sandboxed(&self) -> bool {
self.mounts.is_empty() && self.environ.is_empty()
}
pub(crate) fn build_mount_table(&self) -> Result<MountTable, RuntimeError> {
let mut table = MountTable::new();
for spec in &self.mounts {
table
.mount(&spec.virtual_path, &spec.host_path, spec.access.mount_mode(), None)
.map_err(|err| {
RuntimeError::Internal(format!(
"failed to mount {:?} at {:?}: {}",
spec.host_path, spec.virtual_path, err
))
})?;
}
Ok(table)
}
pub(crate) fn resolve(
&self,
call: OsFunctionCall,
mounts: &mut MountTable,
) -> ExtFunctionResult {
resolve_os_call(call, &self.environ, self.system_clock, mounts)
}
pub(crate) fn prompt_section(&self) -> String {
if self.is_filesystem_and_env_sandboxed() {
let clock = if self.system_clock {
" `date.today()` and `datetime.now()` read the host clock."
} else {
""
};
return format!(
"OS access: this is a sandbox. There is no filesystem access (every path is \
inaccessible) and `os.environ` is empty. Network and subprocess access are not \
available.{clock}"
);
}
let mut section = String::from("OS access (sandboxed):\n");
if self.mounts.is_empty() {
section.push_str(
"- Filesystem: no paths are accessible; any `pathlib.Path` read/write raises \
PermissionError.\n",
);
} else {
section.push_str(
"- Filesystem: use `pathlib.Path` against these mounted paths only; every other \
path raises PermissionError (existence checks return False):\n",
);
for spec in &self.mounts {
section.push_str(&format!(
" - {:?} ({})\n",
spec.virtual_path,
spec.access.label()
));
}
section.push_str(SUPPORTED_PATH_METHODS);
}
if self.environ.is_empty() {
section.push_str(
"- Environment: `os.getenv(name)` returns its default and `os.environ` is empty.\n",
);
} else {
section.push_str(&format!(
"- Environment: `os.getenv(name)` and `os.environ` expose {} variable(s).\n",
self.environ.len()
));
}
if self.system_clock {
section.push_str("- Clock: `date.today()` and `datetime.now()` read the host clock.\n");
} else {
section.push_str("- Clock: `date.today()` / `datetime.now()` are unavailable.\n");
}
section.push_str("- Network and subprocess access are not available.");
section
}
}
impl Default for OsAccess {
fn default() -> Self {
Self::sandboxed()
}
}
#[derive(Debug, Clone)]
pub struct OsAccessBuilder {
mounts: Vec<MountSpec>,
environ: BTreeMap<String, String>,
system_clock: bool,
}
impl OsAccessBuilder {
#[must_use]
pub fn new() -> Self {
Self { mounts: Vec::new(), environ: BTreeMap::new(), system_clock: true }
}
#[must_use]
pub fn allow_path(
mut self,
virtual_path: impl Into<String>,
host_path: impl Into<PathBuf>,
access: PathAccess,
) -> Self {
self.mounts.push(MountSpec {
virtual_path: virtual_path.into(),
host_path: host_path.into(),
access,
});
self
}
#[must_use]
pub fn environ<K, V>(mut self, vars: impl IntoIterator<Item = (K, V)>) -> Self
where
K: Into<String>,
V: Into<String>,
{
self.environ = vars.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
self
}
#[must_use]
pub fn environ_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.environ.insert(key.into(), value.into());
self
}
#[must_use]
pub fn system_clock(mut self, enabled: bool) -> Self {
self.system_clock = enabled;
self
}
#[must_use]
pub fn build(self) -> OsAccess {
OsAccess { mounts: self.mounts, environ: self.environ, system_clock: self.system_clock }
}
}
impl Default for OsAccessBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use adk_code::embedded_python::monty_types::{ExcType, GetenvArgs, MontyObject};
#[test]
fn getenv_returns_value_or_default() {
let access = OsAccess::builder().environ_var("HOME", "/home/agent").build();
let mut mounts = access.build_mount_table().unwrap();
let hit = access.resolve(
OsFunctionCall::Getenv(GetenvArgs {
key: "HOME".to_string(),
default: MontyObject::None,
}),
&mut mounts,
);
assert!(
matches!(hit, ExtFunctionResult::Return(MontyObject::String(s)) if s == "/home/agent")
);
let miss = access.resolve(
OsFunctionCall::Getenv(GetenvArgs {
key: "MISSING".to_string(),
default: MontyObject::String("fallback".to_string()),
}),
&mut mounts,
);
assert!(
matches!(miss, ExtFunctionResult::Return(MontyObject::String(s)) if s == "fallback")
);
}
#[test]
fn environ_projects_the_configured_map() {
let access = OsAccess::builder().environ_var("A", "1").environ_var("B", "2").build();
let mut mounts = access.build_mount_table().unwrap();
let ExtFunctionResult::Return(MontyObject::Dict(pairs)) =
access.resolve(OsFunctionCall::GetEnviron, &mut mounts)
else {
panic!("expected a dict from os.environ");
};
assert_eq!(pairs.len(), 2);
}
#[test]
fn unmounted_read_is_a_permission_error_but_existence_is_false() {
let access = OsAccess::sandboxed();
let mut mounts = access.build_mount_table().unwrap();
let read = access.resolve(OsFunctionCall::ReadText("/etc/passwd".into()), &mut mounts);
match read {
ExtFunctionResult::Error(exc) => assert_eq!(exc.exc_type(), ExcType::PermissionError),
other => panic!("expected PermissionError, got {other:?}"),
}
let exists = access.resolve(OsFunctionCall::Exists("/etc/passwd".into()), &mut mounts);
assert!(matches!(exists, ExtFunctionResult::Return(MontyObject::Bool(false))));
}
#[test]
fn disabled_clock_refuses_date_calls() {
let access = OsAccess::builder().system_clock(false).build();
let mut mounts = access.build_mount_table().unwrap();
let today = access.resolve(OsFunctionCall::DateToday, &mut mounts);
match today {
ExtFunctionResult::Error(exc) => assert_eq!(exc.exc_type(), ExcType::OSError),
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn enabled_clock_returns_a_date() {
let access = OsAccess::sandboxed();
let mut mounts = access.build_mount_table().unwrap();
let today = access.resolve(OsFunctionCall::DateToday, &mut mounts);
assert!(matches!(today, ExtFunctionResult::Return(MontyObject::Date(_))));
}
#[test]
fn prompt_section_lists_mounts_env_and_supported_path_methods() {
let section = OsAccess::builder()
.allow_path("/data", "/srv/data", PathAccess::ReadOnly)
.environ_var("TOKEN", "x")
.build()
.prompt_section();
assert!(section.contains("\"/data\" (read-only)"), "{section}");
assert!(section.contains("expose 1 variable"), "{section}");
assert!(section.contains("subset of `pathlib.Path`"), "{section}");
assert!(section.contains("`read_text()`"), "{section}");
assert!(section.contains("`iterdir()`"), "{section}");
assert!(section.contains("read-write mounts only"), "{section}");
}
#[test]
fn prompt_section_omits_path_methods_when_no_paths_are_mounted() {
let section = OsAccess::builder().environ_var("TOKEN", "x").build().prompt_section();
assert!(section.contains("no paths are accessible"), "{section}");
assert!(!section.contains("subset of `pathlib.Path`"), "{section}");
}
#[test]
fn sandboxed_prompt_section_states_no_access() {
let section = OsAccess::sandboxed().prompt_section();
assert!(section.contains("no filesystem access"), "{section}");
assert!(section.contains("host clock"), "{section}");
}
}