mod convert;
mod drive;
mod host_fn;
mod os_access;
mod prompt;
pub use {monty, monty_fs, monty_types};
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use monty_types::ResourceLimits;
use serde_json::{Map, Value};
use tokio::sync::Mutex;
use tracing::debug;
use crate::{
BackendCapabilities, CodeExecutor, EnvironmentPolicy, ExecutionError, ExecutionIsolation,
ExecutionLanguage, ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus,
FilesystemPolicy, SandboxPolicy, validate_request,
};
use drive::{CappedStdout, DriveEnd, PausedCall, ReplSegment, RunSegment, Tracker};
use host_fn::FunctionRegistry;
use os_access::OsAccess;
use prompt::ModeWording;
pub use convert::{json_to_monty, monty_to_json};
pub use host_fn::{HostFunction, HostFunctionError, MontyBuildError};
pub use os_access::{PathAccess, resolve_os_call};
pub use prompt::SUPPORTED_PATH_METHODS;
const DEFAULT_HOST_FUNCTION_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_MAX_MEMORY: usize = 256 * 1024 * 1024;
#[derive(Clone)]
pub struct MontyExecutorBuilder {
os: OsAccess,
functions: Vec<Arc<dyn HostFunction>>,
max_memory: Option<usize>,
host_function_timeout: Duration,
script_name: String,
}
impl fmt::Debug for MontyExecutorBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MontyExecutorBuilder")
.field("os", &self.os)
.field("functions", &self.functions.iter().map(|hf| hf.name()).collect::<Vec<_>>())
.field("max_memory", &self.max_memory)
.field("host_function_timeout", &self.host_function_timeout)
.field("script_name", &self.script_name)
.finish()
}
}
impl Default for MontyExecutorBuilder {
fn default() -> Self {
Self::new()
}
}
impl MontyExecutorBuilder {
#[must_use]
pub fn new() -> Self {
Self {
os: OsAccess::default(),
functions: Vec::new(),
max_memory: Some(DEFAULT_MAX_MEMORY),
host_function_timeout: DEFAULT_HOST_FUNCTION_TIMEOUT,
script_name: "python_snippet".to_string(),
}
}
#[must_use]
pub fn allow_path(
mut self,
virtual_path: impl Into<String>,
host_path: impl Into<PathBuf>,
access: PathAccess,
) -> Self {
self.os.mounts.push(os_access::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.os.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.os.environ.insert(key.into(), value.into());
self
}
#[must_use]
pub fn system_clock(mut self) -> Self {
self.os.system_clock = true;
self
}
#[must_use]
pub fn function(mut self, function: Arc<dyn HostFunction>) -> Self {
self.functions.push(function);
self
}
#[must_use]
pub fn function_fn<F, Fut>(
self,
name: impl Into<String>,
description: impl Into<String>,
func: F,
) -> Self
where
F: Fn(Vec<Value>, Map<String, Value>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value, HostFunctionError>> + Send + 'static,
{
self.function(Arc::new(host_fn::ClosureHostFunction::new(name, description, func)))
}
#[must_use]
pub fn max_memory(mut self, bytes: usize) -> Self {
self.max_memory = Some(bytes);
self
}
#[must_use]
pub fn host_function_timeout(mut self, timeout: Duration) -> Self {
self.host_function_timeout = timeout;
self
}
#[must_use]
pub fn script_name(mut self, name: impl Into<String>) -> Self {
self.script_name = name.into();
self
}
pub fn build_one_shot(self) -> Result<MontyOneShotExecutor, MontyBuildError> {
Ok(MontyOneShotExecutor { core: self.build_core(ModeWording::OneShot)? })
}
pub fn build_repl(self) -> Result<MontyReplExecutor, MontyBuildError> {
Ok(MontyReplExecutor {
core: self.build_core(ModeWording::Repl)?,
repl_state: Mutex::new(None),
})
}
fn build_core(self, mode: ModeWording) -> Result<Arc<MontyCore>, MontyBuildError> {
os_access::validate_mounts(&self.os.mounts)?;
let registry = FunctionRegistry::build(self.functions)?;
let prompt_snippet = prompt::render_prompt_snippet(&self.os, ®istry, mode);
let mut base_limits = ResourceLimits::new();
if let Some(bytes) = self.max_memory {
base_limits = base_limits.max_memory(bytes);
}
Ok(Arc::new(MontyCore {
grants: self.os,
registry,
base_limits,
script_name: self.script_name,
host_function_timeout: self.host_function_timeout,
prompt_snippet,
}))
}
}
#[derive(Debug)]
struct MontyCore {
grants: OsAccess,
registry: FunctionRegistry,
base_limits: ResourceLimits,
script_name: String,
host_function_timeout: Duration,
prompt_snippet: String,
}
impl MontyCore {
fn tracker(&self, timeout: Duration) -> Tracker {
Tracker::new(self.base_limits.clone().max_duration(timeout))
}
fn granted_policy(&self) -> SandboxPolicy {
let mut policy = SandboxPolicy::strict_python();
if !self.grants.mounts.is_empty() {
let mut read_only = Vec::new();
let mut read_write = Vec::new();
for mount in &self.grants.mounts {
let path = PathBuf::from(&mount.virtual_path);
match mount.access {
PathAccess::ReadOnly => read_only.push(path),
PathAccess::ReadWrite => read_write.push(path),
}
}
policy.filesystem = FilesystemPolicy::Paths { read_only, read_write };
}
if !self.grants.environ.is_empty() {
policy.environment =
EnvironmentPolicy::AllowList(self.grants.environ.keys().cloned().collect());
}
policy
}
async fn call_host_function(
&self,
name: &str,
args: Vec<Value>,
kwargs: Map<String, Value>,
) -> Result<Value, String> {
let Some(function) = self.registry.get(name) else {
return Err(self.registry.unknown_function_message(name));
};
debug!(host_fn.name = %name, args.count = args.len(), "calling host function");
match tokio::time::timeout(self.host_function_timeout, function.call(args, kwargs)).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(err)) => Err(err.to_string()),
Err(_) => Err(format!(
"host function '{name}' timed out after {:?}",
self.host_function_timeout
)),
}
}
}
fn monty_capabilities(persistent: bool) -> BackendCapabilities {
BackendCapabilities {
isolation: ExecutionIsolation::InProcess,
enforce_network_policy: true,
enforce_filesystem_policy: true,
enforce_environment_policy: true,
enforce_timeout: true,
supports_structured_output: true,
supports_process_execution: false,
supports_persistent_workspace: persistent,
supports_interactive_sessions: persistent,
}
}
fn source_code(request: &ExecutionRequest) -> Result<String, ExecutionError> {
match &request.payload {
ExecutionPayload::Source { code } => {
if code.trim().is_empty() {
Err(ExecutionError::InvalidRequest("empty Python source".to_string()))
} else {
Ok(code.clone())
}
}
ExecutionPayload::GuestModule { .. } => Err(ExecutionError::InvalidRequest(
"Monty executors do not support guest modules".to_string(),
)),
}
}
fn truncate_utf8(s: &mut String, max: usize) -> bool {
if s.len() <= max {
return false;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
true
}
fn build_result(
end: DriveEnd,
stdout: CappedStdout,
policy: &SandboxPolicy,
started: Instant,
) -> ExecutionResult {
let (mut stdout, capped) = stdout.into_parts();
let stdout_truncated = truncate_utf8(&mut stdout, policy.max_stdout_bytes) || capped;
let (status, mut stderr) = match end.error {
None => (ExecutionStatus::Success, String::new()),
Some(rendered) if end.timed_out => (ExecutionStatus::Timeout, rendered),
Some(rendered) => (ExecutionStatus::Failed, rendered),
};
let stderr_truncated = truncate_utf8(&mut stderr, policy.max_stderr_bytes);
ExecutionResult {
status,
stdout,
stderr,
output: end.value,
exit_code: None,
stdout_truncated,
stderr_truncated,
duration_ms: started.elapsed().as_millis() as u64,
metadata: None,
}
}
fn join_error(err: tokio::task::JoinError) -> ExecutionError {
ExecutionError::InternalError(format!("interpreter thread panicked: {err}"))
}
#[derive(Debug)]
pub struct MontyOneShotExecutor {
core: Arc<MontyCore>,
}
impl MontyOneShotExecutor {
#[must_use]
pub fn granted_policy(&self) -> SandboxPolicy {
self.core.granted_policy()
}
}
#[async_trait]
impl CodeExecutor for MontyOneShotExecutor {
fn name(&self) -> &str {
"monty-one-shot"
}
fn capabilities(&self) -> BackendCapabilities {
monty_capabilities(false)
}
fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
*lang == ExecutionLanguage::Python
}
fn prompt_snippet(&self) -> Option<String> {
Some(self.core.prompt_snippet.clone())
}
async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
let started = Instant::now();
validate_request(&self.capabilities(), &[ExecutionLanguage::Python], &request)?;
let code = source_code(&request)?;
let os = self.core.grants.narrowed(&request.sandbox)?;
let timeout = request.sandbox.timeout;
let core = &self.core;
let tracker = core.tracker(timeout);
let (mut segment, mut stdout) = {
let os = os.clone();
let registry = core.registry.clone();
let script_name = core.script_name.clone();
let input = request.input.clone();
let mut stdout = CappedStdout::new(request.sandbox.max_stdout_bytes);
tokio::task::spawn_blocking(move || {
let segment = drive::start_run(
&code,
&script_name,
input,
tracker,
&os,
®istry,
&mut stdout,
)?;
Ok::<_, ExecutionError>((segment, stdout))
})
.await
.map_err(join_error)??
};
loop {
match segment {
RunSegment::Finished(end) => {
return Ok(build_result(end, stdout, &request.sandbox, started));
}
RunSegment::Paused(PausedCall { name, args, kwargs, progress_bytes }) => {
let outcome = core.call_host_function(&name, args, kwargs).await;
let os = os.clone();
let registry = core.registry.clone();
(segment, stdout) = tokio::task::spawn_blocking(move || {
let mut stdout = stdout;
let segment = drive::resume_run(
&progress_bytes,
outcome,
&os,
®istry,
&mut stdout,
)?;
Ok::<_, ExecutionError>((segment, stdout))
})
.await
.map_err(join_error)??;
}
}
}
}
}
struct ReplSession {
bytes: Vec<u8>,
policy: Option<OsAccess>,
}
#[derive(Debug)]
pub struct MontyReplExecutor {
core: Arc<MontyCore>,
repl_state: Mutex<Option<ReplSession>>,
}
impl fmt::Debug for ReplSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReplSession")
.field("bytes.len", &self.bytes.len())
.field("policy", &self.policy)
.finish()
}
}
impl MontyReplExecutor {
#[must_use]
pub fn granted_policy(&self) -> SandboxPolicy {
self.core.granted_policy()
}
}
#[async_trait]
impl CodeExecutor for MontyReplExecutor {
fn name(&self) -> &str {
"monty-repl"
}
fn capabilities(&self) -> BackendCapabilities {
monty_capabilities(true)
}
fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
*lang == ExecutionLanguage::Python
}
fn prompt_snippet(&self) -> Option<String> {
Some(self.core.prompt_snippet.clone())
}
async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
let started = Instant::now();
validate_request(&self.capabilities(), &[ExecutionLanguage::Python], &request)?;
let code = source_code(&request)?;
let os = self.core.grants.narrowed(&request.sandbox)?;
let timeout = request.sandbox.timeout;
let mut guard = self.repl_state.lock().await;
if let Some(session) = guard.as_ref()
&& let Some(policy) = session.policy.as_ref()
&& *policy != os
{
return Err(ExecutionError::InvalidRequest(
"the request's effective OS policy differs from this REPL session's established \
policy; a session's mounts and environment must not vary between calls. \
Call restart() to start a fresh session under the new policy."
.to_string(),
));
}
let prior_bytes = guard.as_ref().map(|session| session.bytes.clone());
let core = &self.core;
let tracker = core.tracker(timeout);
let (mut segment, mut stdout) = {
let os = os.clone();
let registry = core.registry.clone();
let script_name = core.script_name.clone();
let input = request.input.clone();
let mut stdout = CappedStdout::new(request.sandbox.max_stdout_bytes);
tokio::task::spawn_blocking(move || {
let segment = drive::feed_repl(
prior_bytes.as_deref(),
&script_name,
tracker,
timeout,
&code,
input,
&os,
®istry,
&mut stdout,
)?;
Ok::<_, ExecutionError>((segment, stdout))
})
.await
.map_err(join_error)??
};
loop {
match segment {
ReplSegment::Finished { end, repl_bytes } => {
*guard = Some(ReplSession { bytes: repl_bytes, policy: Some(os) });
return Ok(build_result(end, stdout, &request.sandbox, started));
}
ReplSegment::Paused(PausedCall { name, args, kwargs, progress_bytes }) => {
let outcome = core.call_host_function(&name, args, kwargs).await;
let os = os.clone();
let registry = core.registry.clone();
(segment, stdout) = tokio::task::spawn_blocking(move || {
let mut stdout = stdout;
let segment = drive::resume_repl(
&progress_bytes,
outcome,
&os,
®istry,
&mut stdout,
)?;
Ok::<_, ExecutionError>((segment, stdout))
})
.await
.map_err(join_error)??;
}
}
}
}
async fn start(&self) -> Result<(), ExecutionError> {
let mut guard = self.repl_state.lock().await;
if guard.is_none() {
let tracker = Tracker::new(self.core.base_limits.clone());
let bytes = drive::fresh_repl_bytes(&self.core.script_name, tracker)?;
*guard = Some(ReplSession { bytes, policy: None });
}
Ok(())
}
async fn stop(&self) -> Result<(), ExecutionError> {
*self.repl_state.lock().await = None;
Ok(())
}
async fn is_running(&self) -> bool {
self.repl_state.lock().await.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn capabilities_encode_the_mode() {
let one_shot = MontyExecutorBuilder::new().build_one_shot().unwrap();
let caps = one_shot.capabilities();
assert!(!caps.supports_interactive_sessions);
assert!(!caps.supports_persistent_workspace);
let repl = MontyExecutorBuilder::new().build_repl().unwrap();
let caps = repl.capabilities();
assert!(caps.supports_interactive_sessions);
assert!(caps.supports_persistent_workspace);
}
#[test]
fn one_cloned_builder_yields_both_products_with_identical_grants() {
let builder = MontyExecutorBuilder::new()
.allow_path("/data", "/srv/data", PathAccess::ReadOnly)
.environ_var("PROJECT", "acme")
.system_clock()
.function_fn("noop", "Do nothing.", |_args, _kwargs| async move { Ok(json!(null)) });
let one_shot = builder.clone().build_one_shot().unwrap();
let repl = builder.build_repl().unwrap();
assert_eq!(one_shot.core.grants, repl.core.grants);
assert_eq!(
one_shot.core.registry.names().collect::<Vec<_>>(),
repl.core.registry.names().collect::<Vec<_>>()
);
assert_ne!(one_shot.core.prompt_snippet, repl.core.prompt_snippet);
}
#[test]
fn build_rejects_invalid_registries() {
let err = MontyExecutorBuilder::new()
.function_fn(
"len",
"Shadow a builtin.",
|_args, _kwargs| async move { Ok(json!(null)) },
)
.build_one_shot()
.unwrap_err();
assert_eq!(err, MontyBuildError::BuiltinCollision("len".to_string()));
let err = MontyExecutorBuilder::new()
.function_fn("dup", "One.", |_args, _kwargs| async move { Ok(json!(null)) })
.function_fn("dup", "Two.", |_args, _kwargs| async move { Ok(json!(null)) })
.build_repl()
.unwrap_err();
assert_eq!(err, MontyBuildError::DuplicateFunctionName("dup".to_string()));
}
#[test]
fn build_rejects_invalid_mount_paths() {
for bad in ["data", "/", "/data/", "/data//sub", "/data/../out"] {
let err = MontyExecutorBuilder::new()
.allow_path(bad, "/srv/data", PathAccess::ReadOnly)
.build_one_shot()
.unwrap_err();
assert!(
matches!(err, MontyBuildError::InvalidMountPath { .. }),
"expected InvalidMountPath for {bad:?}, got {err:?}"
);
}
let err = MontyExecutorBuilder::new()
.allow_path("/data", "/srv/a", PathAccess::ReadOnly)
.allow_path("/data", "/srv/b", PathAccess::ReadWrite)
.build_repl()
.unwrap_err();
assert_eq!(err, MontyBuildError::DuplicateMountPath("/data".to_string()));
}
#[test]
fn granted_policy_mirrors_the_grants() {
let one_shot = MontyExecutorBuilder::new()
.allow_path("/data", "/srv/data", PathAccess::ReadOnly)
.allow_path("/out", "/srv/out", PathAccess::ReadWrite)
.environ_var("PROJECT", "acme")
.build_one_shot()
.unwrap();
let policy = one_shot.granted_policy();
assert_eq!(
policy.filesystem,
FilesystemPolicy::Paths {
read_only: vec![PathBuf::from("/data")],
read_write: vec![PathBuf::from("/out")],
}
);
assert_eq!(policy.environment, EnvironmentPolicy::AllowList(vec!["PROJECT".to_string()]));
let sandboxed = MontyExecutorBuilder::new().build_one_shot().unwrap();
assert_eq!(sandboxed.granted_policy().filesystem, FilesystemPolicy::None);
assert_eq!(sandboxed.granted_policy().environment, EnvironmentPolicy::None);
}
#[test]
fn prompt_snippet_reflects_built_configuration() {
let executor = MontyExecutorBuilder::new()
.allow_path("/data", "/srv/data", PathAccess::ReadOnly)
.environ_var("PROJECT", "secret-value")
.function_fn("noop", "Do nothing.", |_args, _kwargs| async move { Ok(json!(null)) })
.build_repl()
.unwrap();
let snippet = executor.prompt_snippet().expect("monty executors are self-describing");
assert!(snippet.contains("/data (read-only)"));
assert!(snippet.contains("PROJECT"));
assert!(!snippet.contains("secret-value"));
assert!(snippet.contains("def noop(...):"));
}
}