Skip to main content

vsh_monty/
lib.rs

1//! Typed Monty-to-VSH execution adapter.
2//!
3//! The adapter gives Monty no host filesystem mount and answers every typed
4//! [`OsFunctionCall`] plus the stable high-level [`MONTY_VSH_TOOL_NAMES`] from a
5//! caller-owned [`VirtualFs`]. Both surfaces share one active overlay, policy, budget,
6//! and effect ledger. [`InProcessMonty`] is a correctness and embedding harness, not the
7//! production isolation boundary: hostile programs must ultimately run in the
8//! separately supervised Monty worker described by the VSH architecture.
9
10use std::collections::BTreeMap;
11use std::error::Error;
12use std::fmt;
13use std::time::Duration;
14
15use monty::{MontyRun, RunProgress};
16use monty_types::{
17    CompileOptions, DictPairs, ExcType, ExtFunctionResult, FileMode, MontyException,
18    MontyFileHandle, NameLookupResult, PrintWriter, ResourceLimits, ResourceTracker, StringRepr,
19    UnicodeErrorData, UnicodeErrorObject, dir_stat, file_stat, symlink_stat,
20    unicode_decode_error_msg, utf8_error_reason,
21};
22pub use monty_types::{MontyObject, MontyType, OsFunctionCall};
23use vsh_policy::{AccessKind, CallPolicy, DeniedAccess};
24use vsh_types::{ContentVersion, NodeKind, NodeState, RuntimeConfigDigest, VPath, VPathError};
25use vsh_vfs::{EffectOrigin, VfsError, VirtualFs};
26
27mod tools;
28mod worker;
29
30pub use tools::MONTY_VSH_TOOL_NAMES;
31pub use worker::{SubprocessConfig, SubprocessMonty};
32
33/// Canonical absolute path exposed to sandboxed code for the workspace root.
34pub const DEFAULT_VIRTUAL_ROOT: &str = "/workspace";
35const MAX_PYTHON_RESULT_DEPTH: usize = 200;
36
37/// Host surface whose value-conversion contract must accept an execution result.
38#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
39pub enum ResultCompatibility {
40    /// Preserve every valid Monty value for native Rust callers.
41    #[default]
42    Native,
43    /// Reject values the pinned `monty-proto` `PyO3` converter cannot project.
44    Python,
45}
46
47/// A bounded Monty result cannot be represented by the selected host surface.
48#[derive(Clone, Debug, Eq, PartialEq)]
49#[non_exhaustive]
50pub enum ResultCompatibilityError {
51    /// The value exceeds the converter's native-stack recursion backstop.
52    Depth {
53        /// Maximum accepted nesting depth.
54        limit: usize,
55        /// Nesting depth that first exceeded the limit.
56        attempted: usize,
57    },
58    /// A Monty type object has no faithful host-Python type object.
59    TypeObject {
60        /// Monty's stable display name for the unsupported type.
61        name: String,
62    },
63}
64
65impl fmt::Display for ResultCompatibilityError {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Depth { limit, attempted } => write!(
69                formatter,
70                "Python result depth exceeds converter limit: {attempted} > {limit}"
71            ),
72            Self::TypeObject { name } => write!(
73                formatter,
74                "Monty type object {name:?} has no faithful Python projection"
75            ),
76        }
77    }
78}
79
80impl Error for ResultCompatibilityError {}
81
82/// Validate one result before any host mutation that a binding could report as failed.
83///
84/// Native Rust values are always accepted. Python validation mirrors the exact type-object
85/// cases supported by the pinned `monty-proto` converter and its depth backstop.
86///
87/// # Errors
88///
89/// Returns [`ResultCompatibilityError`] when Python projection is not total for `value`.
90pub fn validate_result_compatibility(
91    value: &MontyObject,
92    compatibility: ResultCompatibility,
93) -> Result<(), ResultCompatibilityError> {
94    if compatibility == ResultCompatibility::Native {
95        return Ok(());
96    }
97
98    let mut pending = vec![(value, 1_usize)];
99    while let Some((value, depth)) = pending.pop() {
100        if depth > MAX_PYTHON_RESULT_DEPTH {
101            return Err(ResultCompatibilityError::Depth {
102                limit: MAX_PYTHON_RESULT_DEPTH,
103                attempted: depth,
104            });
105        }
106        if let MontyObject::Type(kind) = value
107            && !python_type_object_is_supported(kind)
108        {
109            return Err(ResultCompatibilityError::TypeObject {
110                name: kind.to_string(),
111            });
112        }
113
114        let child_depth = depth.saturating_add(1);
115        match value {
116            MontyObject::List(values)
117            | MontyObject::Tuple(values)
118            | MontyObject::Set(values)
119            | MontyObject::FrozenSet(values)
120            | MontyObject::NamedTuple { values, .. } => {
121                pending.extend(values.iter().map(|value| (value, child_depth)));
122            }
123            MontyObject::Dict(pairs) => {
124                for (key, value) in pairs {
125                    pending.push((key, child_depth));
126                    pending.push((value, child_depth));
127                }
128            }
129            MontyObject::ClassInstance(instance) => {
130                for (key, value) in instance.attrs.iter().chain(&instance.class_type.attrs) {
131                    pending.push((key, child_depth));
132                    pending.push((value, child_depth));
133                }
134            }
135            _ => {}
136        }
137    }
138    Ok(())
139}
140
141fn python_type_object_is_supported(kind: &MontyType) -> bool {
142    match kind {
143        MontyType::Exception(kind) => !matches!(
144            kind,
145            ExcType::FrozenInstanceError
146                | ExcType::JsonDecodeError
147                | ExcType::UnsupportedOperation
148                | ExcType::RePatternError
149        ),
150        MontyType::Ellipsis
151        | MontyType::Type
152        | MontyType::NoneType
153        | MontyType::Bool
154        | MontyType::Int
155        | MontyType::Float
156        | MontyType::Range
157        | MontyType::Slice
158        | MontyType::Date
159        | MontyType::DateTime
160        | MontyType::TimeDelta
161        | MontyType::TimeZone
162        | MontyType::Str
163        | MontyType::Bytes
164        | MontyType::List
165        | MontyType::Deque
166        | MontyType::ListIterator
167        | MontyType::CallableIterator
168        | MontyType::Tuple
169        | MontyType::Dict
170        | MontyType::Set
171        | MontyType::FrozenSet
172        | MontyType::TextIOWrapper
173        | MontyType::BufferedReader
174        | MontyType::BufferedWriter
175        | MontyType::BufferedRandom
176        | MontyType::SpecialForm
177        | MontyType::Path
178        | MontyType::Property
179        | MontyType::RePattern
180        | MontyType::ReMatch
181        | MontyType::ItertoolsCount
182        | MontyType::ItertoolsRepeat
183        | MontyType::Field
184        | MontyType::ItertoolsPairwise
185        | MontyType::ItertoolsCompress
186        | MontyType::ItertoolsIslice
187        | MontyType::ItertoolsChain
188        | MontyType::ItertoolsCycle => true,
189        _ => false,
190    }
191}
192
193/// Per-execution limits enforced independently from Monty's bytecode tracker.
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub struct ExecutionLimits {
196    /// Maximum UTF-8 bytes accepted as one program.
197    pub max_program_bytes: usize,
198    /// Cumulative time Monty may spend executing bytecode.
199    pub max_duration: Duration,
200    /// Maximum Python call-stack depth.
201    pub max_recursion_depth: usize,
202    /// Maximum interpreter heap bytes enforced by the supervised worker allocator.
203    /// The process-local correctness harness cannot install a per-call global allocator.
204    pub max_memory_bytes: usize,
205    /// Maximum typed OS calls and high-level VSH tool calls serviced by the host adapter.
206    pub max_os_calls: u64,
207    /// Maximum cumulative bytes materialized by read and append operations.
208    pub max_read_bytes: u64,
209    /// Maximum cumulative bytes submitted by write and append operations.
210    pub max_write_bytes: u64,
211    /// Maximum payload bytes materialized by one typed read or write call.
212    pub max_io_call_bytes: usize,
213    /// Maximum UTF-8 bytes accepted in one Monty-visible path.
214    pub max_path_bytes: usize,
215    /// Maximum cumulative directory entries returned to Monty.
216    pub max_directory_entries: u64,
217    /// Maximum UTF-8 bytes retained from `print()` output.
218    pub max_output_bytes: usize,
219    /// Maximum deep host footprint of the returned Monty value.
220    pub max_result_bytes: usize,
221    /// Maximum retained exception message, traceback and structured payload bytes.
222    pub max_exception_bytes: usize,
223}
224
225impl Default for ExecutionLimits {
226    fn default() -> Self {
227        Self {
228            max_program_bytes: 1024 * 1024,
229            max_duration: Duration::from_secs(1),
230            max_recursion_depth: 512,
231            max_memory_bytes: 256 * 1024 * 1024,
232            max_os_calls: 10_000,
233            max_read_bytes: 64 * 1024 * 1024,
234            max_write_bytes: 64 * 1024 * 1024,
235            max_io_call_bytes: 4 * 1024 * 1024,
236            max_path_bytes: 16 * 1024,
237            max_directory_entries: 100_000,
238            max_output_bytes: 1024 * 1024,
239            max_result_bytes: 1024 * 1024,
240            max_exception_bytes: 256 * 1024,
241        }
242    }
243}
244
245/// A validated absolute namespace prefix exposed to Monty.
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub struct VirtualRoot {
248    absolute: String,
249}
250
251impl VirtualRoot {
252    /// Validate and construct a synthetic absolute workspace root.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`VirtualRootError`] unless `absolute` is a normalized POSIX-style
257    /// absolute path without NUL, parent, platform-prefix, or backslash components.
258    pub fn new(absolute: impl Into<String>) -> Result<Self, VirtualRootError> {
259        let absolute = absolute.into();
260        if absolute.contains('\0') {
261            return Err(VirtualRootError::NulByte);
262        }
263        if !absolute.starts_with('/') {
264            return Err(VirtualRootError::NotAbsolute);
265        }
266        if absolute.contains('\\') {
267            return Err(VirtualRootError::PlatformSeparator);
268        }
269
270        let mut components = Vec::new();
271        for component in absolute.split('/') {
272            match component {
273                "" | "." => {}
274                ".." => return Err(VirtualRootError::ParentComponent),
275                value if is_windows_prefix(value) => {
276                    return Err(VirtualRootError::PlatformPrefix);
277                }
278                value => components.push(value),
279            }
280        }
281        let absolute = if components.is_empty() {
282            "/".to_owned()
283        } else {
284            format!("/{}", components.join("/"))
285        };
286        Ok(Self { absolute })
287    }
288
289    /// Return the canonical absolute virtual prefix.
290    #[must_use]
291    pub fn as_str(&self) -> &str {
292        &self.absolute
293    }
294
295    /// Map a Monty-visible path into the relative VSH namespace.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`VirtualPathError`] when the input is malformed or outside this root.
300    pub fn map_path(&self, input: &str) -> Result<VPath, VirtualPathError> {
301        if input.is_empty() {
302            return Err(VirtualPathError::Empty);
303        }
304        if input.contains('\0') {
305            return Err(VirtualPathError::NulByte);
306        }
307
308        let portable = input.replace('\\', "/");
309        if portable.starts_with('/') {
310            let absolute = normalize_absolute(&portable)?;
311            let relative = if self.absolute == "/" {
312                absolute.strip_prefix('/').unwrap_or(&absolute)
313            } else if absolute == self.absolute {
314                ""
315            } else {
316                absolute
317                    .strip_prefix(&self.absolute)
318                    .and_then(|suffix| suffix.strip_prefix('/'))
319                    .ok_or(VirtualPathError::OutsideRoot)?
320            };
321            if relative.is_empty() {
322                Ok(VPath::root())
323            } else {
324                VPath::parse(relative).map_err(VirtualPathError::InvalidRelative)
325            }
326        } else {
327            VPath::parse(&portable).map_err(VirtualPathError::InvalidRelative)
328        }
329    }
330
331    fn present(&self, path: &VPath) -> String {
332        if path.is_root() {
333            return self.absolute.clone();
334        }
335        if self.absolute == "/" {
336            format!("/{}", path.as_str())
337        } else {
338            format!("{}/{}", self.absolute, path.as_str())
339        }
340    }
341}
342
343impl Default for VirtualRoot {
344    fn default() -> Self {
345        Self {
346            absolute: DEFAULT_VIRTUAL_ROOT.to_owned(),
347        }
348    }
349}
350
351/// Invalid synthetic-root configuration.
352#[derive(Clone, Copy, Debug, Eq, PartialEq)]
353#[non_exhaustive]
354pub enum VirtualRootError {
355    /// The configured root was relative.
356    NotAbsolute,
357    /// The root contained a parent component.
358    ParentComponent,
359    /// The root contained a NUL byte.
360    NulByte,
361    /// The root used a platform-specific separator.
362    PlatformSeparator,
363    /// The root contained a drive-style component.
364    PlatformPrefix,
365}
366
367impl fmt::Display for VirtualRootError {
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        formatter.write_str(match self {
370            Self::NotAbsolute => "virtual root must be absolute",
371            Self::ParentComponent => "virtual root contains a parent component",
372            Self::NulByte => "virtual root contains a NUL byte",
373            Self::PlatformSeparator => "virtual root contains a platform separator",
374            Self::PlatformPrefix => "virtual root contains a platform prefix",
375        })
376    }
377}
378
379impl Error for VirtualRootError {}
380
381/// A Monty path that cannot name a node in the configured virtual root.
382#[derive(Clone, Debug, Eq, PartialEq)]
383#[non_exhaustive]
384pub enum VirtualPathError {
385    /// The supplied path was empty.
386    Empty,
387    /// The supplied path contained a NUL byte.
388    NulByte,
389    /// Absolute normalization attempted to move above `/`.
390    EscapesAbsoluteRoot,
391    /// The normalized absolute path was outside the configured VSH root.
392    OutsideRoot,
393    /// Relative VSH path validation rejected the value.
394    InvalidRelative(VPathError),
395}
396
397impl fmt::Display for VirtualPathError {
398    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399        match self {
400            Self::Empty => formatter.write_str("virtual path must not be empty"),
401            Self::NulByte => formatter.write_str("virtual path contains a NUL byte"),
402            Self::EscapesAbsoluteRoot => formatter.write_str("virtual path escapes absolute root"),
403            Self::OutsideRoot => formatter.write_str("virtual path is outside /workspace"),
404            Self::InvalidRelative(source) => {
405                write!(formatter, "invalid relative virtual path: {source}")
406            }
407        }
408    }
409}
410
411impl Error for VirtualPathError {
412    fn source(&self) -> Option<&(dyn Error + 'static)> {
413        match self {
414            Self::InvalidRelative(source) => Some(source),
415            Self::Empty | Self::NulByte | Self::EscapesAbsoluteRoot | Self::OutsideRoot => None,
416        }
417    }
418}
419
420/// Configuration for the process-local Monty correctness harness.
421#[derive(Clone, Debug, Eq, PartialEq)]
422pub struct InProcessConfig {
423    virtual_root: VirtualRoot,
424    environment: BTreeMap<String, String>,
425    limits: ExecutionLimits,
426    script_name: String,
427    call_policy: CallPolicy,
428}
429
430impl InProcessConfig {
431    /// Construct a config with a caller-selected virtual root and safe defaults.
432    #[must_use]
433    pub fn new(virtual_root: VirtualRoot) -> Self {
434        let mut environment = BTreeMap::new();
435        environment.insert("HOME".to_owned(), "/home/vsh".to_owned());
436        environment.insert("PWD".to_owned(), virtual_root.as_str().to_owned());
437        Self {
438            virtual_root,
439            environment,
440            limits: ExecutionLimits::default(),
441            script_name: "<vsh>".to_owned(),
442            call_policy: CallPolicy::default(),
443        }
444    }
445
446    /// Replace all independently enforced execution limits.
447    #[must_use]
448    pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
449        self.limits = limits;
450        self
451    }
452
453    /// Replace the complete synthetic environment exposed to Monty.
454    #[must_use]
455    pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
456        self.environment = environment;
457        self
458    }
459
460    /// Set the synthetic script name used in Monty tracebacks.
461    #[must_use]
462    pub fn with_script_name(mut self, script_name: impl Into<String>) -> Self {
463        self.script_name = script_name.into();
464        self
465    }
466
467    /// Replace the complete pre-call path-capability policy.
468    #[must_use]
469    pub fn with_call_policy(mut self, call_policy: CallPolicy) -> Self {
470        self.call_policy = call_policy;
471        self
472    }
473
474    /// Return the virtual namespace mapping used by this harness.
475    #[must_use]
476    pub const fn virtual_root(&self) -> &VirtualRoot {
477        &self.virtual_root
478    }
479
480    /// Return the configured independent host limits.
481    #[must_use]
482    pub const fn limits(&self) -> ExecutionLimits {
483        self.limits
484    }
485
486    /// Return the pre-call path-capability policy.
487    #[must_use]
488    pub const fn call_policy(&self) -> &CallPolicy {
489        &self.call_policy
490    }
491
492    /// Hash every security-relevant execution setting for transaction binding.
493    #[must_use]
494    pub fn security_digest(&self) -> RuntimeConfigDigest {
495        let mut canonical = Vec::new();
496        encode_string("vsh-monty-config-v3", &mut canonical);
497        encode_string(env!("CARGO_PKG_VERSION"), &mut canonical);
498        encode_string("monty-0.0.22", &mut canonical);
499        encode_string(self.virtual_root.as_str(), &mut canonical);
500        encode_string(&self.script_name, &mut canonical);
501        encode_u64(self.limits.max_program_bytes, &mut canonical);
502        canonical.extend_from_slice(&self.limits.max_duration.as_nanos().to_le_bytes());
503        encode_u64(self.limits.max_recursion_depth, &mut canonical);
504        encode_u64(self.limits.max_memory_bytes, &mut canonical);
505        canonical.extend_from_slice(&self.limits.max_os_calls.to_le_bytes());
506        canonical.extend_from_slice(&self.limits.max_read_bytes.to_le_bytes());
507        canonical.extend_from_slice(&self.limits.max_write_bytes.to_le_bytes());
508        encode_u64(self.limits.max_io_call_bytes, &mut canonical);
509        encode_u64(self.limits.max_path_bytes, &mut canonical);
510        canonical.extend_from_slice(&self.limits.max_directory_entries.to_le_bytes());
511        encode_u64(self.limits.max_output_bytes, &mut canonical);
512        encode_u64(self.limits.max_result_bytes, &mut canonical);
513        encode_u64(self.limits.max_exception_bytes, &mut canonical);
514        encode_u64(self.environment.len(), &mut canonical);
515        for (key, value) in &self.environment {
516            encode_string(key, &mut canonical);
517            encode_string(value, &mut canonical);
518        }
519        RuntimeConfigDigest::digest_canonical(&canonical)
520    }
521}
522
523fn encode_string(value: &str, output: &mut Vec<u8>) {
524    encode_u64(value.len(), output);
525    output.extend_from_slice(value.as_bytes());
526}
527
528fn encode_u64(value: usize, output: &mut Vec<u8>) {
529    output.extend_from_slice(&u64::try_from(value).unwrap_or(u64::MAX).to_le_bytes());
530}
531
532impl Default for InProcessConfig {
533    fn default() -> Self {
534        Self::new(VirtualRoot::default())
535    }
536}
537
538/// Why execution stopped before a normal Monty result was produced.
539#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540#[non_exhaustive]
541pub enum ExecutionLimitExceeded {
542    /// Program source exceeded its input cap before compilation.
543    ProgramBytes {
544        /// Configured maximum.
545        limit: u64,
546        /// Submitted UTF-8 byte count.
547        attempted: u64,
548    },
549    /// Typed OS-call count exceeded its cap.
550    OsCalls {
551        /// Configured maximum.
552        limit: u64,
553        /// Count the next call would have reached.
554        attempted: u64,
555    },
556    /// Cumulative materialized read bytes exceeded their cap.
557    ReadBytes {
558        /// Configured maximum.
559        limit: u64,
560        /// Byte count the operation would have reached.
561        attempted: u64,
562    },
563    /// Cumulative submitted write bytes exceeded their cap.
564    WriteBytes {
565        /// Configured maximum.
566        limit: u64,
567        /// Byte count the operation would have reached.
568        attempted: u64,
569    },
570    /// One typed read payload exceeded its per-call materialization cap.
571    ReadCallBytes {
572        /// Configured maximum.
573        limit: u64,
574        /// Bytes the call would materialize.
575        attempted: u64,
576    },
577    /// One typed write payload exceeded its per-call decode cap.
578    WriteCallBytes {
579        /// Configured maximum.
580        limit: u64,
581        /// Submitted bytes in this call.
582        attempted: u64,
583    },
584    /// One Monty-visible path exceeded its UTF-8 byte cap.
585    PathBytes {
586        /// Configured maximum.
587        limit: u64,
588        /// Submitted path bytes.
589        attempted: u64,
590    },
591    /// Cumulative returned directory entries exceeded their cap.
592    DirectoryEntries {
593        /// Configured maximum.
594        limit: u64,
595        /// Entry count the operation would have reached.
596        attempted: u64,
597    },
598    /// Streamed print output exceeded its retained UTF-8 byte cap.
599    OutputBytes {
600        /// Configured maximum.
601        limit: u64,
602        /// Bytes observed before stopping.
603        attempted: u64,
604    },
605    /// A completed return value exceeded its deep host-footprint cap.
606    ResultBytes {
607        /// Configured maximum.
608        limit: u64,
609        /// Deep bytes visited before stopping.
610        attempted: u64,
611    },
612    /// An escaping exception exceeded its host-output cap.
613    ExceptionBytes {
614        /// Configured maximum.
615        limit: u64,
616        /// Retained exception bytes.
617        attempted: u64,
618    },
619}
620
621impl fmt::Display for ExecutionLimitExceeded {
622    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
623        let (name, limit, attempted) = match *self {
624            Self::ProgramBytes { limit, attempted } => ("program bytes", limit, attempted),
625            Self::OsCalls { limit, attempted } => ("OS calls", limit, attempted),
626            Self::ReadBytes { limit, attempted } => ("read bytes", limit, attempted),
627            Self::WriteBytes { limit, attempted } => ("write bytes", limit, attempted),
628            Self::ReadCallBytes { limit, attempted } => ("read call bytes", limit, attempted),
629            Self::WriteCallBytes { limit, attempted } => ("write call bytes", limit, attempted),
630            Self::PathBytes { limit, attempted } => ("path bytes", limit, attempted),
631            Self::DirectoryEntries { limit, attempted } => ("directory entries", limit, attempted),
632            Self::OutputBytes { limit, attempted } => ("output bytes", limit, attempted),
633            Self::ResultBytes { limit, attempted } => ("result bytes", limit, attempted),
634            Self::ExceptionBytes { limit, attempted } => ("exception bytes", limit, attempted),
635        };
636        write!(formatter, "{name} limit exceeded: {attempted} > {limit}")
637    }
638}
639
640impl Error for ExecutionLimitExceeded {}
641
642/// Phase in which Monty raised an exception outside sandboxed exception handling.
643#[derive(Clone, Copy, Debug, Eq, PartialEq)]
644pub enum MontyFailurePhase {
645    /// Source parsing or bytecode preparation.
646    Compile,
647    /// Initial execution or a resumed typed call.
648    Runtime,
649}
650
651/// Supervised-worker failure category.
652#[derive(Clone, Copy, Debug, Eq, PartialEq)]
653#[non_exhaustive]
654pub enum WorkerFailureKind {
655    /// The exact configured worker executable could not be validated or spawned.
656    Spawn,
657    /// A framed request or response could not be transferred.
658    Transport,
659    /// The child violated the typed Monty protocol.
660    Protocol,
661    /// The child stopped without a valid turn-ending response.
662    Crashed,
663    /// The parent wall-clock watchdog expired and terminated the child.
664    Timeout,
665}
666
667/// Failure reported by the supervised subprocess boundary.
668#[derive(Debug)]
669pub struct WorkerFailure {
670    /// Stable failure category.
671    pub kind: WorkerFailureKind,
672    /// Bounded diagnostic suitable for logs and mapped errors.
673    pub detail: String,
674}
675
676impl fmt::Display for WorkerFailure {
677    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
678        write!(
679            formatter,
680            "Monty worker {:?} failure: {}",
681            self.kind, self.detail
682        )
683    }
684}
685
686impl Error for WorkerFailure {}
687
688/// Failure of a Monty execution adapter.
689#[derive(Debug)]
690#[non_exhaustive]
691pub enum ExecutionError {
692    /// Monty compilation or runtime failed.
693    Monty {
694        /// Failure phase.
695        phase: MontyFailurePhase,
696        /// Exact Monty exception and traceback.
697        source: Box<MontyException>,
698    },
699    /// An independent host-side budget was exceeded and execution was not resumed.
700    Limit(Box<ExecutionLimitExceeded>),
701    /// VSH snapshot/blob integrity failed; this is never exposed as a catchable Python error.
702    InternalVfs(Box<VfsError>),
703    /// Monty requested a capability this harness deliberately does not provide.
704    UnsupportedSuspension {
705        /// Suspension category.
706        kind: &'static str,
707        /// Function name when Monty supplied one.
708        name: Option<String>,
709    },
710    /// The supervised worker failed outside sandboxed Python semantics.
711    Worker(Box<WorkerFailure>),
712}
713
714impl fmt::Display for ExecutionError {
715    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
716        match self {
717            Self::Monty { phase, source } => write!(formatter, "Monty {phase:?} failure: {source}"),
718            Self::Limit(source) => write!(formatter, "execution budget failure: {source}"),
719            Self::InternalVfs(source) => write!(formatter, "internal VFS failure: {source}"),
720            Self::UnsupportedSuspension { kind, name } => match name {
721                Some(name) => write!(formatter, "unsupported Monty {kind}: {name}"),
722                None => write!(formatter, "unsupported Monty {kind}"),
723            },
724            Self::Worker(source) => source.fmt(formatter),
725        }
726    }
727}
728
729impl Error for ExecutionError {
730    fn source(&self) -> Option<&(dyn Error + 'static)> {
731        match self {
732            Self::Monty { source, .. } => Some(source),
733            Self::Limit(source) => Some(source),
734            Self::InternalVfs(source) => Some(source),
735            Self::UnsupportedSuspension { .. } => None,
736            Self::Worker(source) => Some(source),
737        }
738    }
739}
740
741/// Host-side counters from one execution.
742#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
743pub struct ExecutionStats {
744    /// Typed OS calls and high-level VSH tool calls serviced.
745    pub os_calls: u64,
746    /// File bytes materialized for reads or copy-on-write append.
747    pub read_bytes: u64,
748    /// Payload bytes submitted to writes or appends.
749    pub write_bytes: u64,
750    /// Directory entries returned to Monty.
751    pub directory_entries: u64,
752    /// UTF-8 output bytes retained after completion.
753    pub output_bytes: usize,
754    /// Protected capability attempts denied before any VFS access.
755    pub denied_accesses: u64,
756    /// Deep host footprint of the final returned value.
757    pub result_bytes: u64,
758}
759
760/// Successful result of a process-local Monty execution.
761#[derive(Debug)]
762pub struct ExecutionOutcome {
763    /// Final Monty value.
764    pub value: MontyObject,
765    /// Bounded output captured from `print()`.
766    pub stdout: String,
767    /// Independent host-side budget counters.
768    pub stats: ExecutionStats,
769    /// Denials retained even when sandboxed code caught the `PermissionError`.
770    pub denied_accesses: Vec<DeniedAccess>,
771}
772
773/// Process-local typed adapter used for correctness tests and trusted embedding.
774///
775/// This type deliberately has no host mount, host environment access, network access,
776/// process API, or committer. It does not isolate interpreter crashes; use the planned
777/// supervised worker boundary for hostile production execution.
778#[derive(Clone, Debug, Default)]
779pub struct InProcessMonty {
780    config: InProcessConfig,
781}
782
783impl InProcessMonty {
784    /// Construct the adapter from explicit synthetic-host configuration.
785    #[must_use]
786    pub const fn new(config: InProcessConfig) -> Self {
787        Self { config }
788    }
789
790    /// Execute source against a caller-owned virtual transaction.
791    ///
792    /// # Errors
793    ///
794    /// Returns [`ExecutionError`] for uncaught Monty exceptions, hard host limits,
795    /// integrity failures, or unsupported external-capability suspensions.
796    pub fn execute(
797        &self,
798        code: impl Into<String>,
799        filesystem: &mut VirtualFs,
800    ) -> Result<ExecutionOutcome, ExecutionError> {
801        let code = code.into();
802        let program_bytes = u64::try_from(code.len()).unwrap_or(u64::MAX);
803        let max_program_bytes =
804            u64::try_from(self.config.limits.max_program_bytes).unwrap_or(u64::MAX);
805        if program_bytes > max_program_bytes {
806            return Err(limit_error(ExecutionLimitExceeded::ProgramBytes {
807                limit: max_program_bytes,
808                attempted: program_bytes,
809            }));
810        }
811        let (input_names, input_values) = tools::inputs();
812        let run = MontyRun::new(
813            code,
814            &self.config.script_name,
815            input_names,
816            CompileOptions::default(),
817        )
818        .map_err(|source| self.monty_error(MontyFailurePhase::Compile, source))?;
819
820        let resource_limits = ResourceLimits::default()
821            .max_duration(self.config.limits.max_duration)
822            .max_recursion_depth(self.config.limits.max_recursion_depth)
823            .max_suspensions(
824                usize::try_from(self.config.limits.max_os_calls).unwrap_or(usize::MAX),
825            );
826        let tracker = ResourceTracker::new(resource_limits);
827        let mut stdout = String::new();
828        let mut budget = Budget::new(self.config.limits);
829        let mut denied_accesses = Vec::new();
830        let mut progress = run
831            .start(input_values, tracker, self.print_writer(&mut stdout))
832            .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?;
833
834        loop {
835            progress = match progress {
836                RunProgress::Complete(value) => {
837                    let mut stats = budget.stats;
838                    stats.output_bytes = stdout.len();
839                    stats.result_bytes =
840                        measure_result(&value, self.config.limits.max_result_bytes)
841                            .map_err(limit_error)?;
842                    return Ok(ExecutionOutcome {
843                        value,
844                        stdout,
845                        stats,
846                        denied_accesses,
847                    });
848                }
849                RunProgress::OsCall(call) => {
850                    budget.charge_os_call().map_err(limit_error)?;
851                    let result =
852                        filesystem.with_effect_origin(EffectOrigin::MontyOsCall, |filesystem| {
853                            dispatch_call(
854                                &call.function_call,
855                                filesystem,
856                                &self.config,
857                                &mut budget,
858                            )
859                        });
860                    let result = call_result(result, &mut budget, &mut denied_accesses)?;
861                    call.resume(result, self.print_writer(&mut stdout))
862                        .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?
863                }
864                RunProgress::NameLookup(lookup) => lookup
865                    .resume(NameLookupResult::Undefined, self.print_writer(&mut stdout))
866                    .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?,
867                RunProgress::FunctionCall(call) => {
868                    if call.object_id.is_some() || !tools::is_tool(&call.function_name) {
869                        return Err(ExecutionError::UnsupportedSuspension {
870                            kind: "external function call",
871                            name: Some(call.function_name),
872                        });
873                    }
874                    budget.charge_os_call().map_err(limit_error)?;
875                    let result =
876                        filesystem.with_effect_origin(EffectOrigin::MontyToolCall, |filesystem| {
877                            tools::dispatch(
878                                &call.function_name,
879                                &call.args,
880                                &call.kwargs,
881                                filesystem,
882                                &self.config,
883                                &mut budget,
884                            )
885                        });
886                    let result = call_result(result, &mut budget, &mut denied_accesses)?;
887                    call.resume(result, self.print_writer(&mut stdout))
888                        .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?
889                }
890                RunProgress::ResolveFutures(_) => {
891                    return Err(ExecutionError::UnsupportedSuspension {
892                        kind: "future resolution",
893                        name: None,
894                    });
895                }
896            };
897        }
898    }
899
900    fn print_writer<'a>(&self, stdout: &'a mut String) -> PrintWriter<'a> {
901        PrintWriter::CollectString(stdout, Some(self.config.limits.max_output_bytes))
902    }
903
904    fn monty_error(&self, phase: MontyFailurePhase, source: MontyException) -> ExecutionError {
905        let attempted = exception_bytes(&source);
906        let limit = u64::try_from(self.config.limits.max_exception_bytes).unwrap_or(u64::MAX);
907        if attempted > limit {
908            limit_error(ExecutionLimitExceeded::ExceptionBytes { limit, attempted })
909        } else {
910            ExecutionError::Monty {
911                phase,
912                source: Box::new(source),
913            }
914        }
915    }
916}
917
918fn limit_error(source: ExecutionLimitExceeded) -> ExecutionError {
919    ExecutionError::Limit(Box::new(source))
920}
921
922fn call_result(
923    result: Result<MontyObject, CallFailure>,
924    budget: &mut Budget,
925    denied_accesses: &mut Vec<DeniedAccess>,
926) -> Result<ExtFunctionResult, ExecutionError> {
927    match result {
928        Ok(value) => Ok(ExtFunctionResult::Return(value)),
929        Err(CallFailure::Python(exception)) => Ok(ExtFunctionResult::Error(exception)),
930        Err(CallFailure::Policy(denial)) => {
931            budget.stats.denied_accesses = budget.stats.denied_accesses.saturating_add(1);
932            let exception = permission_denied(denial.path.as_str());
933            denied_accesses.push(denial);
934            Ok(ExtFunctionResult::Error(exception))
935        }
936        Err(CallFailure::Limit(source)) => Err(limit_error(source)),
937        Err(CallFailure::InternalVfs(source)) => Err(ExecutionError::InternalVfs(Box::new(source))),
938    }
939}
940
941struct Budget {
942    limits: ExecutionLimits,
943    stats: ExecutionStats,
944}
945
946impl Budget {
947    const fn new(limits: ExecutionLimits) -> Self {
948        Self {
949            limits,
950            stats: ExecutionStats {
951                os_calls: 0,
952                read_bytes: 0,
953                write_bytes: 0,
954                directory_entries: 0,
955                output_bytes: 0,
956                denied_accesses: 0,
957                result_bytes: 0,
958            },
959        }
960    }
961
962    fn charge_os_call(&mut self) -> Result<(), ExecutionLimitExceeded> {
963        charge(
964            &mut self.stats.os_calls,
965            1,
966            self.limits.max_os_calls,
967            |limit, attempted| ExecutionLimitExceeded::OsCalls { limit, attempted },
968        )
969    }
970
971    fn charge_read(&mut self, bytes: u64) -> Result<(), CallFailure> {
972        let call_limit = u64::try_from(self.limits.max_io_call_bytes).unwrap_or(u64::MAX);
973        if bytes > call_limit {
974            return Err(CallFailure::Limit(ExecutionLimitExceeded::ReadCallBytes {
975                limit: call_limit,
976                attempted: bytes,
977            }));
978        }
979        charge(
980            &mut self.stats.read_bytes,
981            bytes,
982            self.limits.max_read_bytes,
983            |limit, attempted| ExecutionLimitExceeded::ReadBytes { limit, attempted },
984        )
985        .map_err(CallFailure::Limit)
986    }
987
988    fn charge_write(&mut self, bytes: usize) -> Result<(), CallFailure> {
989        let bytes = u64::try_from(bytes).unwrap_or(u64::MAX);
990        let call_limit = u64::try_from(self.limits.max_io_call_bytes).unwrap_or(u64::MAX);
991        if bytes > call_limit {
992            return Err(CallFailure::Limit(ExecutionLimitExceeded::WriteCallBytes {
993                limit: call_limit,
994                attempted: bytes,
995            }));
996        }
997        charge(
998            &mut self.stats.write_bytes,
999            bytes,
1000            self.limits.max_write_bytes,
1001            |limit, attempted| ExecutionLimitExceeded::WriteBytes { limit, attempted },
1002        )
1003        .map_err(CallFailure::Limit)
1004    }
1005
1006    fn charge_directory_entries(&mut self, entries: usize) -> Result<(), CallFailure> {
1007        let entries = u64::try_from(entries).unwrap_or(u64::MAX);
1008        charge(
1009            &mut self.stats.directory_entries,
1010            entries,
1011            self.limits.max_directory_entries,
1012            |limit, attempted| ExecutionLimitExceeded::DirectoryEntries { limit, attempted },
1013        )
1014        .map_err(CallFailure::Limit)
1015    }
1016}
1017
1018fn charge<E>(
1019    used: &mut u64,
1020    amount: u64,
1021    limit: u64,
1022    error: impl FnOnce(u64, u64) -> E,
1023) -> Result<(), E> {
1024    let attempted = used.saturating_add(amount);
1025    if attempted > limit {
1026        Err(error(limit, attempted))
1027    } else {
1028        *used = attempted;
1029        Ok(())
1030    }
1031}
1032
1033fn measure_result(value: &MontyObject, limit: usize) -> Result<u64, ExecutionLimitExceeded> {
1034    let limit = u64::try_from(limit).unwrap_or(u64::MAX);
1035    let mut used = 0_u64;
1036    let mut pending = vec![value];
1037    while let Some(value) = pending.pop() {
1038        let bytes = u64::try_from(value.host_size()).unwrap_or(u64::MAX);
1039        let attempted = used.saturating_add(bytes);
1040        if attempted > limit {
1041            return Err(ExecutionLimitExceeded::ResultBytes { limit, attempted });
1042        }
1043        used = attempted;
1044        match value {
1045            MontyObject::List(values)
1046            | MontyObject::Tuple(values)
1047            | MontyObject::Set(values)
1048            | MontyObject::FrozenSet(values)
1049            | MontyObject::NamedTuple { values, .. } => pending.extend(values),
1050            MontyObject::Dict(pairs) => {
1051                for (key, value) in pairs {
1052                    pending.push(key);
1053                    pending.push(value);
1054                }
1055            }
1056            MontyObject::ClassInstance(instance) => {
1057                for (key, value) in instance.attrs.iter().chain(&instance.class_type.attrs) {
1058                    pending.push(key);
1059                    pending.push(value);
1060                }
1061            }
1062            _ => {}
1063        }
1064    }
1065    Ok(used)
1066}
1067
1068fn exception_bytes(source: &MontyException) -> u64 {
1069    let mut bytes = u64::try_from(size_of::<MontyException>()).unwrap_or(u64::MAX);
1070    bytes = bytes.saturating_add(source.message().map_or(0, |message| {
1071        u64::try_from(message.len()).unwrap_or(u64::MAX)
1072    }));
1073    for frame in source.traceback() {
1074        bytes = bytes
1075            .saturating_add(u64::try_from(size_of_val(frame)).unwrap_or(u64::MAX))
1076            .saturating_add(u64::try_from(frame.filename.len()).unwrap_or(u64::MAX))
1077            .saturating_add(
1078                frame
1079                    .frame_name
1080                    .as_ref()
1081                    .map_or(0, |name| u64::try_from(name.len()).unwrap_or(u64::MAX)),
1082            )
1083            .saturating_add(
1084                frame
1085                    .preview_line
1086                    .as_ref()
1087                    .map_or(0, |line| u64::try_from(line.len()).unwrap_or(u64::MAX)),
1088            );
1089    }
1090    if let Some(data) = source.data().unicode() {
1091        bytes = bytes
1092            .saturating_add(u64::try_from(data.encoding.len()).unwrap_or(u64::MAX))
1093            .saturating_add(u64::try_from(data.reason.len()).unwrap_or(u64::MAX))
1094            .saturating_add(match &data.object {
1095                UnicodeErrorObject::Bytes(value) => u64::try_from(value.len()).unwrap_or(u64::MAX),
1096                UnicodeErrorObject::Str(value) => u64::try_from(value.len()).unwrap_or(u64::MAX),
1097            });
1098    }
1099    if let Some(data) = source.data().json() {
1100        bytes = bytes
1101            .saturating_add(u64::try_from(data.msg.len()).unwrap_or(u64::MAX))
1102            .saturating_add(
1103                data.doc
1104                    .as_ref()
1105                    .map_or(0, |doc| u64::try_from(doc.len()).unwrap_or(u64::MAX)),
1106            );
1107    }
1108    bytes
1109}
1110
1111enum CallFailure {
1112    Python(MontyException),
1113    Policy(DeniedAccess),
1114    Limit(ExecutionLimitExceeded),
1115    InternalVfs(VfsError),
1116}
1117
1118#[expect(
1119    clippy::too_many_lines,
1120    reason = "keeping the exhaustive typed Monty boundary in one match makes new upstream variants fail compilation"
1121)]
1122fn dispatch_call(
1123    call: &OsFunctionCall,
1124    filesystem: &mut VirtualFs,
1125    config: &InProcessConfig,
1126    budget: &mut Budget,
1127) -> Result<MontyObject, CallFailure> {
1128    match call {
1129        OsFunctionCall::Exists(path) => {
1130            bool_query(call, path.as_str(), filesystem, config, |state| {
1131                state.is_some()
1132            })
1133        }
1134        OsFunctionCall::IsFile(path) => {
1135            bool_query(call, path.as_str(), filesystem, config, |state| {
1136                state.is_some_and(|state| state.kind() == NodeKind::File)
1137            })
1138        }
1139        OsFunctionCall::IsDir(path) => {
1140            bool_query(call, path.as_str(), filesystem, config, |state| {
1141                state.is_some_and(|state| state.kind() == NodeKind::Directory)
1142            })
1143        }
1144        OsFunctionCall::IsSymlink(path) => {
1145            bool_query(call, path.as_str(), filesystem, config, |state| {
1146                state.is_some_and(|state| state.kind() == NodeKind::Symlink)
1147            })
1148        }
1149        OsFunctionCall::ReadText(path) => {
1150            read_text(call, path.as_str(), filesystem, config, budget)
1151        }
1152        OsFunctionCall::ReadBytes(path) => {
1153            read_bytes(call, path.as_str(), filesystem, config, budget)
1154        }
1155        OsFunctionCall::Stat(path) => stat(call, path.as_str(), filesystem, config),
1156        OsFunctionCall::Iterdir(path) => {
1157            read_directory(call, path.as_str(), filesystem, config, budget)
1158        }
1159        OsFunctionCall::Resolve(path) | OsFunctionCall::Absolute(path) => {
1160            absolute_path(call, path.as_str(), config)
1161        }
1162        OsFunctionCall::WriteText(args) => {
1163            budget.charge_write(args.data.len())?;
1164            write_bytes(
1165                call,
1166                args.path.as_str(),
1167                args.data.as_bytes(),
1168                filesystem,
1169                config,
1170            )?;
1171            Ok(MontyObject::Int(
1172                i64::try_from(args.data.chars().count()).unwrap_or(i64::MAX),
1173            ))
1174        }
1175        OsFunctionCall::WriteBytes(args) => {
1176            budget.charge_write(args.data.len())?;
1177            write_bytes(call, args.path.as_str(), &args.data, filesystem, config)?;
1178            Ok(MontyObject::Int(
1179                i64::try_from(args.data.len()).unwrap_or(i64::MAX),
1180            ))
1181        }
1182        OsFunctionCall::AppendText(args) => {
1183            budget.charge_write(args.data.len())?;
1184            append_bytes(
1185                call,
1186                args.path.as_str(),
1187                args.data.as_bytes(),
1188                filesystem,
1189                config,
1190                budget,
1191            )?;
1192            Ok(MontyObject::Int(
1193                i64::try_from(args.data.chars().count()).unwrap_or(i64::MAX),
1194            ))
1195        }
1196        OsFunctionCall::AppendBytes(args) => {
1197            budget.charge_write(args.data.len())?;
1198            append_bytes(
1199                call,
1200                args.path.as_str(),
1201                &args.data,
1202                filesystem,
1203                config,
1204                budget,
1205            )?;
1206            Ok(MontyObject::Int(
1207                i64::try_from(args.data.len()).unwrap_or(i64::MAX),
1208            ))
1209        }
1210        OsFunctionCall::Open(args) => {
1211            open_file(call, args.path.as_str(), args.mode, filesystem, config)
1212        }
1213        OsFunctionCall::Mkdir(args) => mkdir(
1214            call,
1215            args.path.as_str(),
1216            args.parents,
1217            args.exist_ok,
1218            filesystem,
1219            config,
1220        ),
1221        OsFunctionCall::Unlink(path) => {
1222            let mapped =
1223                map_authorized_path(call, path.as_str(), false, config, &[AccessKind::Delete])?;
1224            vfs(filesystem.unlink(&mapped), path.as_str())?;
1225            Ok(MontyObject::None)
1226        }
1227        OsFunctionCall::Rmdir(path) => {
1228            let mapped =
1229                map_authorized_path(call, path.as_str(), false, config, &[AccessKind::Delete])?;
1230            vfs(filesystem.rmdir(&mapped), path.as_str())?;
1231            Ok(MontyObject::None)
1232        }
1233        OsFunctionCall::Rename(args) => {
1234            let source = map_authorized_path(
1235                call,
1236                args.src.as_str(),
1237                false,
1238                config,
1239                &[AccessKind::RenameSource],
1240            )?;
1241            let destination = map_authorized_path(
1242                call,
1243                args.dst.as_str(),
1244                true,
1245                config,
1246                &[AccessKind::RenameDestination],
1247            )?;
1248            vfs(filesystem.rename(&source, &destination), args.src.as_str())?;
1249            Ok(MontyObject::None)
1250        }
1251        OsFunctionCall::Getenv(args) => Ok(config.environment.get(&args.key).map_or_else(
1252            || args.default.clone(),
1253            |value| MontyObject::String(value.clone()),
1254        )),
1255        OsFunctionCall::GetEnviron => {
1256            let pairs = config
1257                .environment
1258                .iter()
1259                .map(|(key, value)| {
1260                    (
1261                        MontyObject::String(key.clone()),
1262                        MontyObject::String(value.clone()),
1263                    )
1264                })
1265                .collect::<Vec<_>>();
1266            Ok(MontyObject::Dict(DictPairs::from(pairs)))
1267        }
1268        OsFunctionCall::DateToday | OsFunctionCall::DateTimeNow(_) => {
1269            Err(CallFailure::Python(call.on_no_handler()))
1270        }
1271    }
1272}
1273
1274fn bool_query(
1275    call: &OsFunctionCall,
1276    raw: &str,
1277    filesystem: &mut VirtualFs,
1278    config: &InProcessConfig,
1279    predicate: impl FnOnce(Option<NodeState>) -> bool,
1280) -> Result<MontyObject, CallFailure> {
1281    check_path_bytes(raw, config)?;
1282    let Ok(path) = config.virtual_root.map_path(raw) else {
1283        return Ok(MontyObject::Bool(false));
1284    };
1285    config
1286        .call_policy
1287        .authorize(&path, AccessKind::MetadataRead)
1288        .map_err(CallFailure::Policy)?;
1289    let state = match filesystem.metadata(&path) {
1290        Ok(state) => Some(state),
1291        Err(VfsError::NotFound { .. }) => None,
1292        Err(source) => return Err(classify_vfs(source, raw)),
1293    };
1294    let _ = call;
1295    Ok(MontyObject::Bool(predicate(state)))
1296}
1297
1298fn read_text(
1299    call: &OsFunctionCall,
1300    raw: &str,
1301    filesystem: &mut VirtualFs,
1302    config: &InProcessConfig,
1303    budget: &mut Budget,
1304) -> Result<MontyObject, CallFailure> {
1305    let bytes = read_file(call, raw, filesystem, config, budget)?;
1306    match String::from_utf8(bytes) {
1307        Ok(text) => Ok(MontyObject::String(text)),
1308        Err(error) => {
1309            let utf8 = error.utf8_error();
1310            let start = utf8.valid_up_to();
1311            let end = utf8
1312                .error_len()
1313                .map_or(error.as_bytes().len(), |length| start + length);
1314            let first_byte = error.as_bytes()[start];
1315            let reason = utf8_error_reason(first_byte, utf8.error_len());
1316            let data = UnicodeErrorData::decode("utf-8", error.as_bytes(), start, end, reason);
1317            Err(CallFailure::Python(
1318                MontyException::new(
1319                    ExcType::UnicodeDecodeError,
1320                    Some(unicode_decode_error_msg(
1321                        "utf-8", first_byte, start, end, reason,
1322                    )),
1323                )
1324                .with_data(data),
1325            ))
1326        }
1327    }
1328}
1329
1330fn read_bytes(
1331    call: &OsFunctionCall,
1332    raw: &str,
1333    filesystem: &mut VirtualFs,
1334    config: &InProcessConfig,
1335    budget: &mut Budget,
1336) -> Result<MontyObject, CallFailure> {
1337    read_file(call, raw, filesystem, config, budget).map(MontyObject::Bytes)
1338}
1339
1340fn read_file(
1341    call: &OsFunctionCall,
1342    raw: &str,
1343    filesystem: &mut VirtualFs,
1344    config: &InProcessConfig,
1345    budget: &mut Budget,
1346) -> Result<Vec<u8>, CallFailure> {
1347    let path = map_authorized_path(call, raw, false, config, &[AccessKind::ContentRead])?;
1348    let state = vfs(filesystem.metadata(&path), raw)?;
1349    if state.kind() != NodeKind::File {
1350        return Err(not_regular(raw, state.kind()));
1351    }
1352    budget.charge_read(state.size())?;
1353    vfs(filesystem.read(&path), raw)
1354}
1355
1356fn write_bytes(
1357    call: &OsFunctionCall,
1358    raw: &str,
1359    bytes: &[u8],
1360    filesystem: &mut VirtualFs,
1361    config: &InProcessConfig,
1362) -> Result<(), CallFailure> {
1363    let path = map_authorized_path(
1364        call,
1365        raw,
1366        false,
1367        config,
1368        &[AccessKind::Create, AccessKind::Modify],
1369    )?;
1370    vfs(filesystem.write(&path, bytes), raw)
1371}
1372
1373fn append_bytes(
1374    call: &OsFunctionCall,
1375    raw: &str,
1376    bytes: &[u8],
1377    filesystem: &mut VirtualFs,
1378    config: &InProcessConfig,
1379    budget: &mut Budget,
1380) -> Result<(), CallFailure> {
1381    let path = map_authorized_path(
1382        call,
1383        raw,
1384        false,
1385        config,
1386        &[AccessKind::Create, AccessKind::Modify],
1387    )?;
1388    match filesystem.metadata(&path) {
1389        Ok(state) if state.kind() == NodeKind::File => {
1390            budget.charge_read(state.size())?;
1391            vfs(filesystem.append(&path, bytes), raw)
1392        }
1393        Ok(state) => Err(not_regular(raw, state.kind())),
1394        Err(VfsError::NotFound { .. }) => vfs(filesystem.write(&path, bytes), raw),
1395        Err(source) => Err(classify_vfs(source, raw)),
1396    }
1397}
1398
1399fn read_directory(
1400    call: &OsFunctionCall,
1401    raw: &str,
1402    filesystem: &mut VirtualFs,
1403    config: &InProcessConfig,
1404    budget: &mut Budget,
1405) -> Result<MontyObject, CallFailure> {
1406    let path = map_authorized_path(call, raw, false, config, &[AccessKind::DirectoryRead])?;
1407    let children = vfs(filesystem.read_dir(&path), raw)?;
1408    budget.charge_directory_entries(children.len())?;
1409    Ok(MontyObject::List(
1410        children
1411            .iter()
1412            .filter(|child| {
1413                config
1414                    .call_policy
1415                    .authorize(child, AccessKind::MetadataRead)
1416                    .is_ok()
1417            })
1418            .map(|child| MontyObject::Path(config.virtual_root.present(child)))
1419            .collect(),
1420    ))
1421}
1422
1423fn stat(
1424    call: &OsFunctionCall,
1425    raw: &str,
1426    filesystem: &mut VirtualFs,
1427    config: &InProcessConfig,
1428) -> Result<MontyObject, CallFailure> {
1429    let path = map_authorized_path(call, raw, false, config, &[AccessKind::MetadataRead])?;
1430    let state = vfs(filesystem.metadata(&path), raw)?;
1431    let mtime = match state.content() {
1432        Some(ContentVersion::Stamp(stamp)) => u64::try_from(stamp.mtime_ns)
1433            .map(Duration::from_nanos)
1434            .map_or(0.0, |duration| duration.as_secs_f64()),
1435        _ => 0.0,
1436    };
1437    let mode = i64::from(state.mode());
1438    let size = i64::try_from(state.size()).unwrap_or(i64::MAX);
1439    match state.kind() {
1440        NodeKind::File => Ok(file_stat(mode, size, mtime)),
1441        NodeKind::Directory => Ok(dir_stat(mode, mtime)),
1442        NodeKind::Symlink => Ok(symlink_stat(mode, mtime)),
1443    }
1444}
1445
1446fn absolute_path(
1447    call: &OsFunctionCall,
1448    raw: &str,
1449    config: &InProcessConfig,
1450) -> Result<MontyObject, CallFailure> {
1451    let path = map_call_path(call, raw, false, config)?;
1452    Ok(MontyObject::Path(config.virtual_root.present(&path)))
1453}
1454
1455fn open_file(
1456    call: &OsFunctionCall,
1457    raw: &str,
1458    mode: FileMode,
1459    filesystem: &mut VirtualFs,
1460    config: &InProcessConfig,
1461) -> Result<MontyObject, CallFailure> {
1462    let accesses: &[AccessKind] = match mode {
1463        FileMode::Read(_) => &[AccessKind::ContentRead],
1464        FileMode::ReadUpdate(_) | FileMode::WriteUpdate(_) | FileMode::AppendUpdate(_) => &[
1465            AccessKind::ContentRead,
1466            AccessKind::Create,
1467            AccessKind::Modify,
1468        ],
1469        FileMode::Write(_) | FileMode::Append(_) => &[AccessKind::Create, AccessKind::Modify],
1470    };
1471    let path = map_authorized_path(call, raw, false, config, accesses)?;
1472    match mode {
1473        FileMode::Read(_) | FileMode::ReadUpdate(_) => {
1474            let state = vfs(filesystem.metadata(&path), raw)?;
1475            if state.kind() != NodeKind::File {
1476                return Err(not_regular(raw, state.kind()));
1477            }
1478        }
1479        FileMode::Write(_) | FileMode::WriteUpdate(_) => {
1480            vfs(filesystem.write(&path, &[]), raw)?;
1481        }
1482        FileMode::Append(_) | FileMode::AppendUpdate(_) => match filesystem.metadata(&path) {
1483            Ok(state) if state.kind() == NodeKind::File => {}
1484            Ok(state) => return Err(not_regular(raw, state.kind())),
1485            Err(VfsError::NotFound { .. }) => vfs(filesystem.write(&path, &[]), raw)?,
1486            Err(source) => return Err(classify_vfs(source, raw)),
1487        },
1488    }
1489    Ok(MontyObject::FileHandle(MontyFileHandle {
1490        path: config.virtual_root.present(&path),
1491        mode,
1492        position: 0,
1493    }))
1494}
1495
1496fn mkdir(
1497    call: &OsFunctionCall,
1498    raw: &str,
1499    parents: bool,
1500    exist_ok: bool,
1501    filesystem: &mut VirtualFs,
1502    config: &InProcessConfig,
1503) -> Result<MontyObject, CallFailure> {
1504    let path = map_call_path(call, raw, false, config)?;
1505    authorize_path(config, &path, &[AccessKind::Create, AccessKind::Modify])?;
1506    if parents {
1507        let mut ancestor = path.parent();
1508        while let Some(candidate) = ancestor {
1509            if candidate.is_root() {
1510                break;
1511            }
1512            authorize_path(
1513                config,
1514                &candidate,
1515                &[AccessKind::Create, AccessKind::Modify],
1516            )?;
1517            ancestor = candidate.parent();
1518        }
1519    }
1520    match filesystem.metadata(&path) {
1521        Ok(state) if exist_ok && state.kind() == NodeKind::Directory => {
1522            return Ok(MontyObject::None);
1523        }
1524        Ok(_) => return Err(already_exists(raw)),
1525        Err(VfsError::NotFound { .. }) => {}
1526        Err(source) => return Err(classify_vfs(source, raw)),
1527    }
1528
1529    if !parents {
1530        vfs(filesystem.mkdir(&path, 0o755), raw)?;
1531        return Ok(MontyObject::None);
1532    }
1533
1534    let mut missing = vec![path.clone()];
1535    let mut cursor = path.parent();
1536    while let Some(parent) = cursor {
1537        match filesystem.metadata(&parent) {
1538            Ok(state) if state.kind() == NodeKind::Directory => break,
1539            Ok(_) => return Err(not_directory(raw)),
1540            Err(VfsError::NotFound { .. }) => {
1541                cursor = parent.parent();
1542                missing.push(parent);
1543            }
1544            Err(source) => return Err(classify_vfs(source, raw)),
1545        }
1546    }
1547    for directory in missing.iter().rev() {
1548        vfs(filesystem.mkdir(directory, 0o755), raw)?;
1549    }
1550    Ok(MontyObject::None)
1551}
1552
1553fn map_call_path(
1554    call: &OsFunctionCall,
1555    raw: &str,
1556    destination: bool,
1557    config: &InProcessConfig,
1558) -> Result<VPath, CallFailure> {
1559    check_path_bytes(raw, config)?;
1560    config.virtual_root.map_path(raw).map_err(|source| {
1561        if source == VirtualPathError::NulByte {
1562            CallFailure::Python(MontyException::new(
1563                ExcType::ValueError,
1564                Some(call.embedded_null_message(destination).to_owned()),
1565            ))
1566        } else {
1567            CallFailure::Python(permission_denied(raw))
1568        }
1569    })
1570}
1571
1572fn check_path_bytes(raw: &str, config: &InProcessConfig) -> Result<(), CallFailure> {
1573    let attempted = u64::try_from(raw.len()).unwrap_or(u64::MAX);
1574    let limit = u64::try_from(config.limits.max_path_bytes).unwrap_or(u64::MAX);
1575    if attempted > limit {
1576        Err(CallFailure::Limit(ExecutionLimitExceeded::PathBytes {
1577            limit,
1578            attempted,
1579        }))
1580    } else {
1581        Ok(())
1582    }
1583}
1584
1585fn map_authorized_path(
1586    call: &OsFunctionCall,
1587    raw: &str,
1588    destination: bool,
1589    config: &InProcessConfig,
1590    accesses: &[AccessKind],
1591) -> Result<VPath, CallFailure> {
1592    let path = map_call_path(call, raw, destination, config)?;
1593    authorize_path(config, &path, accesses)?;
1594    Ok(path)
1595}
1596
1597fn authorize_path(
1598    config: &InProcessConfig,
1599    path: &VPath,
1600    accesses: &[AccessKind],
1601) -> Result<(), CallFailure> {
1602    for access in accesses {
1603        config
1604            .call_policy
1605            .authorize(path, *access)
1606            .map_err(CallFailure::Policy)?;
1607    }
1608    Ok(())
1609}
1610
1611fn vfs<T>(result: Result<T, VfsError>, raw: &str) -> Result<T, CallFailure> {
1612    result.map_err(|source| classify_vfs(source, raw))
1613}
1614
1615fn classify_vfs(source: VfsError, raw: &str) -> CallFailure {
1616    let exception = match source {
1617        VfsError::NotFound { .. } => file_not_found(raw),
1618        VfsError::AlreadyExists { .. } => already_exists_exception(raw),
1619        VfsError::NotDirectory { .. } => not_directory_exception(raw),
1620        VfsError::NotFile {
1621            actual: NodeKind::Directory,
1622            ..
1623        }
1624        | VfsError::IsDirectory { .. } => is_directory_exception(raw),
1625        VfsError::NotFile { .. } | VfsError::NotSymlink { .. } | VfsError::RootMutation => {
1626            permission_denied(raw)
1627        }
1628        VfsError::DirectoryNotEmpty { .. } => MontyException::new(
1629            ExcType::OSError,
1630            Some(format!(
1631                "[Errno 39] Directory not empty: {}",
1632                StringRepr(raw)
1633            )),
1634        ),
1635        VfsError::InvalidRename { .. } | VfsError::RenameTypeMismatch { .. } => {
1636            MontyException::new(
1637                ExcType::OSError,
1638                Some(format!("[Errno 22] Invalid argument: {}", StringRepr(raw))),
1639            )
1640        }
1641        internal @ (VfsError::Snapshot(_) | VfsError::Store(_) | VfsError::Path(_)) => {
1642            return CallFailure::InternalVfs(internal);
1643        }
1644        internal => return CallFailure::InternalVfs(internal),
1645    };
1646    CallFailure::Python(exception)
1647}
1648
1649fn file_not_found(raw: &str) -> MontyException {
1650    MontyException::new(
1651        ExcType::FileNotFoundError,
1652        Some(format!(
1653            "[Errno 2] No such file or directory: {}",
1654            StringRepr(raw)
1655        )),
1656    )
1657}
1658
1659fn already_exists(raw: &str) -> CallFailure {
1660    CallFailure::Python(already_exists_exception(raw))
1661}
1662
1663fn already_exists_exception(raw: &str) -> MontyException {
1664    MontyException::new(
1665        ExcType::FileExistsError,
1666        Some(format!("[Errno 17] File exists: {}", StringRepr(raw))),
1667    )
1668}
1669
1670fn not_regular(raw: &str, kind: NodeKind) -> CallFailure {
1671    if kind == NodeKind::Directory {
1672        CallFailure::Python(is_directory_exception(raw))
1673    } else {
1674        CallFailure::Python(permission_denied(raw))
1675    }
1676}
1677
1678fn is_directory_exception(raw: &str) -> MontyException {
1679    MontyException::new(
1680        ExcType::IsADirectoryError,
1681        Some(format!("[Errno 21] Is a directory: {}", StringRepr(raw))),
1682    )
1683}
1684
1685fn not_directory(raw: &str) -> CallFailure {
1686    CallFailure::Python(not_directory_exception(raw))
1687}
1688
1689fn not_directory_exception(raw: &str) -> MontyException {
1690    MontyException::new(
1691        ExcType::NotADirectoryError,
1692        Some(format!("[Errno 20] Not a directory: {}", StringRepr(raw))),
1693    )
1694}
1695
1696fn permission_denied(raw: &str) -> MontyException {
1697    MontyException::new(
1698        ExcType::PermissionError,
1699        Some(format!("[Errno 13] Permission denied: {}", StringRepr(raw))),
1700    )
1701}
1702
1703fn normalize_absolute(input: &str) -> Result<String, VirtualPathError> {
1704    let mut components = Vec::new();
1705    for component in input.split('/') {
1706        match component {
1707            "" | "." => {}
1708            ".." => {
1709                if components.pop().is_none() {
1710                    return Err(VirtualPathError::EscapesAbsoluteRoot);
1711                }
1712            }
1713            value => components.push(value),
1714        }
1715    }
1716    if components.is_empty() {
1717        Ok("/".to_owned())
1718    } else {
1719        Ok(format!("/{}", components.join("/")))
1720    }
1721}
1722
1723fn is_windows_prefix(component: &str) -> bool {
1724    let bytes = component.as_bytes();
1725    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730    use std::collections::BTreeMap;
1731    use std::error::Error;
1732    use std::fs;
1733    use std::path::{Path, PathBuf};
1734    use std::sync::atomic::{AtomicU64, Ordering};
1735
1736    use monty_types::ExcType;
1737    use vsh_policy::{CallPolicy, DenyReason, PolicyDecision, PolicyInput, TransactionPolicy};
1738    use vsh_store::BlobStore;
1739    use vsh_types::{DiffKind, VPath};
1740    use vsh_vfs::{EffectOrigin, SnapshotBuilder, VfsError, VirtualFs};
1741
1742    use super::{
1743        ExecutionError, ExecutionLimitExceeded, ExecutionLimits, InProcessConfig, InProcessMonty,
1744        MontyObject, MontyType, ResultCompatibility, ResultCompatibilityError, VirtualPathError,
1745        VirtualRoot, VirtualRootError, WorkerFailure, WorkerFailureKind,
1746        validate_result_compatibility,
1747    };
1748
1749    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1750
1751    struct TestDirectory(PathBuf);
1752
1753    impl TestDirectory {
1754        fn new() -> Self {
1755            let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1756            let path = std::env::temp_dir()
1757                .join(format!("vsh-monty-test-{}-{sequence}", std::process::id()));
1758            fs::create_dir(&path).expect("test directory should be unique");
1759            Self(path)
1760        }
1761
1762        fn path(&self) -> &Path {
1763            &self.0
1764        }
1765    }
1766
1767    impl Drop for TestDirectory {
1768        fn drop(&mut self) {
1769            let _ = fs::remove_dir_all(&self.0);
1770        }
1771    }
1772
1773    fn filesystem(files: &[(&str, &[u8])]) -> (TestDirectory, VirtualFs) {
1774        let directory = TestDirectory::new();
1775        let store = BlobStore::open(directory.path()).expect("blob store should open");
1776        let mut builder = SnapshotBuilder::new(store);
1777        for (path, bytes) in files {
1778            builder
1779                .add_file(
1780                    VPath::parse(path).expect("test path should parse"),
1781                    bytes,
1782                    0o644,
1783                )
1784                .expect("test file should be added");
1785        }
1786        let snapshot = builder.build().expect("snapshot should build");
1787        (directory, VirtualFs::new(snapshot))
1788    }
1789
1790    #[test]
1791    fn virtual_root_maps_only_its_absolute_namespace() {
1792        let root = VirtualRoot::new("/workspace/").expect("root should normalize");
1793        assert_eq!(root.as_str(), "/workspace");
1794        assert_eq!(
1795            root.map_path("/workspace/src/../README.md")
1796                .expect("path should map"),
1797            VPath::parse("README.md").unwrap()
1798        );
1799        assert_eq!(
1800            root.map_path("/etc/passwd"),
1801            Err(VirtualPathError::OutsideRoot)
1802        );
1803        assert_eq!(
1804            root.map_path("/workspace/../../etc/passwd"),
1805            Err(VirtualPathError::EscapesAbsoluteRoot)
1806        );
1807    }
1808
1809    #[test]
1810    fn monty_program_produces_exact_virtual_diff() {
1811        let (_directory, mut filesystem) = filesystem(&[("input.txt", b"hello\n")]);
1812        let outcome = InProcessMonty::default()
1813            .execute(
1814                r"
1815from pathlib import Path
1816source = Path('/workspace/input.txt').read_text()
1817Path('/workspace/out').mkdir()
1818Path('/workspace/out/result.txt').write_text(source.upper())
1819Path('/workspace/input.txt').rename('/workspace/archive.txt')
1820len(source)
1821",
1822                &mut filesystem,
1823            )
1824            .expect("program should execute");
1825
1826        assert_eq!(outcome.value, MontyObject::Int(6));
1827        assert_eq!(outcome.stats.os_calls, 4);
1828        assert_eq!(outcome.stats.read_bytes, 6);
1829        assert_eq!(outcome.stats.write_bytes, 6);
1830        assert!(
1831            filesystem
1832                .effects()
1833                .iter()
1834                .all(|event| event.origin == EffectOrigin::MontyOsCall)
1835        );
1836
1837        let diff = filesystem
1838            .canonical_diff()
1839            .expect("diff should be canonical");
1840        let changes = diff
1841            .entries()
1842            .iter()
1843            .map(|entry| (entry.path.as_str(), entry.kind))
1844            .collect::<Vec<_>>();
1845        assert_eq!(
1846            changes,
1847            vec![
1848                ("archive.txt", DiffKind::Create),
1849                ("input.txt", DiffKind::Delete),
1850                ("out", DiffKind::Create),
1851                ("out/result.txt", DiffKind::Create),
1852            ]
1853        );
1854        assert_eq!(
1855            filesystem
1856                .read(&VPath::parse("out/result.txt").unwrap())
1857                .unwrap(),
1858            b"HELLO\n"
1859        );
1860    }
1861
1862    #[test]
1863    fn monty_tools_and_pathlib_share_one_active_virtual_filesystem() {
1864        let (_directory, mut filesystem) = filesystem(&[]);
1865        let outcome = InProcessMonty::default()
1866            .execute(
1867                r"
1868from pathlib import Path
1869vsh_mkdir('/workspace/out')
1870vsh_write('/workspace/out/tool.txt', 'hello')
1871seen_by_pathlib = Path('/workspace/out/tool.txt').read_text()
1872Path('/workspace/out/pathlib.txt').write_text(seen_by_pathlib.upper())
1873seen_by_tool = vsh_read('/workspace/out/pathlib.txt')
1874(seen_by_pathlib, seen_by_tool, len(vsh_list('/workspace/out')))
1875",
1876                &mut filesystem,
1877            )
1878            .expect("VSH functions and pathlib should interoperate");
1879
1880        assert_eq!(
1881            outcome.value,
1882            MontyObject::Tuple(vec![
1883                MontyObject::String("hello".to_owned()),
1884                MontyObject::String("HELLO".to_owned()),
1885                MontyObject::Int(2),
1886            ])
1887        );
1888        assert_eq!(
1889            filesystem
1890                .read(&VPath::parse("out/pathlib.txt").unwrap())
1891                .unwrap(),
1892            b"HELLO"
1893        );
1894        assert!(
1895            filesystem
1896                .effects()
1897                .iter()
1898                .any(|event| event.origin == EffectOrigin::MontyToolCall)
1899        );
1900        assert!(
1901            filesystem
1902                .effects()
1903                .iter()
1904                .any(|event| event.origin == EffectOrigin::MontyOsCall)
1905        );
1906    }
1907
1908    #[test]
1909    fn monty_tools_copy_glob_search_patch_move_and_remove_virtual_state() {
1910        let (_directory, mut filesystem) = filesystem(&[]);
1911        let outcome = InProcessMonty::default()
1912            .execute(
1913                r"
1914vsh_mkdir('/workspace/src/nested')
1915vsh_write('/workspace/src/a.txt', 'Needle one\n')
1916vsh_write('/workspace/src/nested/b.txt', 'needle two\n')
1917vsh_copy('/workspace/src', '/workspace/copied', recursive=True)
1918paths = vsh_glob('**/*.txt', path='/workspace/copied')
1919hits = vsh_search('needle', path='/workspace/copied', case_sensitive=False)
1920changed = vsh_patch('/workspace/copied/a.txt', 'Needle', 'Found')
1921vsh_move('/workspace/copied/a.txt', '/workspace/copied/renamed.txt')
1922vsh_remove('/workspace/copied/nested', recursive=True)
1923(len(paths), len(hits), changed, vsh_read('/workspace/copied/renamed.txt'), len(vsh_list('/workspace/copied')))
1924",
1925                &mut filesystem,
1926            )
1927            .expect("high-level VSH functions should compose on one overlay");
1928
1929        assert_eq!(
1930            outcome.value,
1931            MontyObject::Tuple(vec![
1932                MontyObject::Int(2),
1933                MontyObject::Int(2),
1934                MontyObject::Int(1),
1935                MontyObject::String("Found one\n".to_owned()),
1936                MontyObject::Int(1),
1937            ])
1938        );
1939        assert_eq!(
1940            filesystem
1941                .read(&VPath::parse("copied/renamed.txt").unwrap())
1942                .unwrap(),
1943            b"Found one\n"
1944        );
1945        assert!(!filesystem.exists(&VPath::parse("copied/nested").unwrap()));
1946    }
1947
1948    #[test]
1949    fn bounded_glob_and_search_stop_walking_after_enough_results() {
1950        let (_directory, mut filesystem) = filesystem(&[
1951            ("a.txt", b"needle"),
1952            ("b.txt", b"needle"),
1953            ("c.txt", b"needle"),
1954        ]);
1955        let outcome = InProcessMonty::default()
1956            .execute(
1957                r"
1958paths = vsh_glob('*.txt', max_results=1)
1959hits = vsh_search('needle', max_results=1)
1960(paths, hits[0]['path'])
1961",
1962                &mut filesystem,
1963            )
1964            .expect("bounded discovery should complete");
1965
1966        assert_eq!(
1967            outcome.value,
1968            MontyObject::Tuple(vec![
1969                MontyObject::List(vec![MontyObject::Path("/workspace/a.txt".to_owned(),)]),
1970                MontyObject::Path("/workspace/a.txt".to_owned()),
1971            ])
1972        );
1973        assert_eq!(outcome.stats.read_bytes, 6);
1974    }
1975
1976    #[test]
1977    fn zero_result_discovery_validates_the_root_without_walking_it() {
1978        let (_directory, mut filesystem) = filesystem(&[("a.txt", b"needle")]);
1979        let outcome = InProcessMonty::default()
1980            .execute(
1981                r"
1982(vsh_glob('*.txt', max_results=0), vsh_search('needle', max_results=0))
1983",
1984                &mut filesystem,
1985            )
1986            .expect("zero-result discovery should remain valid and bounded");
1987
1988        assert_eq!(
1989            outcome.value,
1990            MontyObject::Tuple(vec![MontyObject::List(vec![]), MontyObject::List(vec![])])
1991        );
1992        assert_eq!(outcome.stats.directory_entries, 0);
1993        assert_eq!(outcome.stats.read_bytes, 0);
1994    }
1995
1996    #[test]
1997    fn absolute_host_path_never_falls_back_to_host() {
1998        let (directory, mut filesystem) = filesystem(&[]);
1999        let host_file = directory.path().join("host-secret.txt");
2000        fs::write(&host_file, b"secret").expect("host sentinel should be written");
2001        let code = format!(
2002            "from pathlib import Path\nPath({:?}).exists()",
2003            host_file.to_string_lossy()
2004        );
2005        let outcome = InProcessMonty::default()
2006            .execute(code, &mut filesystem)
2007            .expect("existence check should safely complete");
2008        assert_eq!(outcome.value, MontyObject::Bool(false));
2009        assert_eq!(outcome.stats.read_bytes, 0);
2010    }
2011
2012    #[test]
2013    fn traversal_read_is_denied_without_resuming_host_access() {
2014        let (_directory, mut filesystem) = filesystem(&[]);
2015        let error = InProcessMonty::default()
2016            .execute(
2017                "from pathlib import Path\nPath('/workspace/../../etc/passwd').read_text()",
2018                &mut filesystem,
2019            )
2020            .expect_err("traversal must fail");
2021        let ExecutionError::Monty { source, .. } = error else {
2022            panic!("expected a Monty exception")
2023        };
2024        assert_eq!(source.exc_type(), ExcType::PermissionError);
2025    }
2026
2027    #[test]
2028    fn independent_os_call_limit_is_hard() {
2029        let (_directory, mut filesystem) = filesystem(&[]);
2030        let limits = ExecutionLimits {
2031            max_os_calls: 2,
2032            ..ExecutionLimits::default()
2033        };
2034        let engine = InProcessMonty::new(InProcessConfig::default().with_limits(limits));
2035        let error = engine
2036            .execute(
2037                r"
2038from pathlib import Path
2039Path('/workspace/a').exists()
2040Path('/workspace/b').exists()
2041Path('/workspace/c').exists()
2042",
2043                &mut filesystem,
2044            )
2045            .expect_err("third call must be rejected");
2046        assert!(matches!(
2047            error,
2048            ExecutionError::Limit(source)
2049                if *source == ExecutionLimitExceeded::OsCalls { limit: 2, attempted: 3 }
2050        ));
2051    }
2052
2053    #[test]
2054    fn high_level_vsh_tools_share_the_hard_os_call_budget() {
2055        let (_directory, mut filesystem) = filesystem(&[]);
2056        let limits = ExecutionLimits {
2057            max_os_calls: 2,
2058            ..ExecutionLimits::default()
2059        };
2060        let engine = InProcessMonty::new(InProcessConfig::default().with_limits(limits));
2061        let error = engine
2062            .execute(
2063                r"
2064vsh_write('/workspace/a.txt', 'a')
2065vsh_read('/workspace/a.txt')
2066vsh_list('/workspace')
2067",
2068                &mut filesystem,
2069            )
2070            .expect_err("the third high-level VSH call must be rejected");
2071        assert!(matches!(
2072            error,
2073            ExecutionError::Limit(source)
2074                if *source == ExecutionLimitExceeded::OsCalls { limit: 2, attempted: 3 }
2075        ));
2076    }
2077
2078    #[test]
2079    fn per_call_payload_and_path_limits_stop_before_vfs_mutation() {
2080        let (_directory, mut filesystem) = filesystem(&[("input.txt", b"four")]);
2081        let limits = ExecutionLimits {
2082            max_io_call_bytes: 3,
2083            ..ExecutionLimits::default()
2084        };
2085        let error = InProcessMonty::new(InProcessConfig::default().with_limits(limits))
2086            .execute(
2087                "from pathlib import Path\nPath('/workspace/input.txt').read_bytes()",
2088                &mut filesystem,
2089            )
2090            .unwrap_err();
2091        assert!(matches!(
2092            error,
2093            ExecutionError::Limit(source)
2094                if *source == ExecutionLimitExceeded::ReadCallBytes {
2095                    limit: 3,
2096                    attempted: 4,
2097                }
2098        ));
2099        let error = InProcessMonty::new(InProcessConfig::default().with_limits(limits))
2100            .execute(
2101                "from pathlib import Path\nPath('/workspace/output.txt').write_text('four')",
2102                &mut filesystem,
2103            )
2104            .unwrap_err();
2105        assert!(matches!(
2106            error,
2107            ExecutionError::Limit(source)
2108                if *source == ExecutionLimitExceeded::WriteCallBytes {
2109                    limit: 3,
2110                    attempted: 4,
2111                }
2112        ));
2113
2114        let limits = ExecutionLimits {
2115            max_path_bytes: 8,
2116            ..ExecutionLimits::default()
2117        };
2118        let error = InProcessMonty::new(InProcessConfig::default().with_limits(limits))
2119            .execute(
2120                "from pathlib import Path\nPath('/workspace/too-long').write_text('x')",
2121                &mut filesystem,
2122            )
2123            .unwrap_err();
2124        assert!(matches!(
2125            error,
2126            ExecutionError::Limit(source)
2127                if matches!(*source, ExecutionLimitExceeded::PathBytes { limit: 8, .. })
2128        ));
2129        assert!(filesystem.canonical_diff().unwrap().is_empty());
2130    }
2131
2132    #[test]
2133    fn program_result_and_exception_outputs_have_independent_hard_caps() {
2134        let (_directory, mut filesystem) = filesystem(&[]);
2135        let program_limits = ExecutionLimits {
2136            max_program_bytes: 4,
2137            ..ExecutionLimits::default()
2138        };
2139        let error = InProcessMonty::new(InProcessConfig::default().with_limits(program_limits))
2140            .execute("'too long'", &mut filesystem)
2141            .unwrap_err();
2142        assert!(matches!(
2143            error,
2144            ExecutionError::Limit(source)
2145                if matches!(*source, ExecutionLimitExceeded::ProgramBytes { limit: 4, .. })
2146        ));
2147
2148        let result_limits = ExecutionLimits {
2149            max_result_bytes: 128,
2150            ..ExecutionLimits::default()
2151        };
2152        let error = InProcessMonty::new(InProcessConfig::default().with_limits(result_limits))
2153            .execute("'x' * 1_000", &mut filesystem)
2154            .unwrap_err();
2155        assert!(matches!(
2156            error,
2157            ExecutionError::Limit(source)
2158                if matches!(*source, ExecutionLimitExceeded::ResultBytes { limit: 128, .. })
2159        ));
2160
2161        let exception_limits = ExecutionLimits {
2162            max_exception_bytes: 128,
2163            ..ExecutionLimits::default()
2164        };
2165        let error = InProcessMonty::new(InProcessConfig::default().with_limits(exception_limits))
2166            .execute("raise ValueError('x' * 1_000)", &mut filesystem)
2167            .unwrap_err();
2168        assert!(matches!(
2169            error,
2170            ExecutionError::Limit(source)
2171                if matches!(*source, ExecutionLimitExceeded::ExceptionBytes { limit: 128, .. })
2172        ));
2173    }
2174
2175    #[test]
2176    fn security_digest_changes_with_synthetic_environment_and_limits() {
2177        let base = InProcessConfig::default();
2178        let mut environment = BTreeMap::new();
2179        environment.insert("PWD".to_owned(), "/workspace".to_owned());
2180        let changed_environment = base.clone().with_environment(environment);
2181        let changed_limit = base.clone().with_limits(ExecutionLimits {
2182            max_os_calls: base.limits().max_os_calls - 1,
2183            ..base.limits()
2184        });
2185
2186        assert_ne!(
2187            base.security_digest(),
2188            changed_environment.security_digest()
2189        );
2190        assert_ne!(base.security_digest(), changed_limit.security_digest());
2191        assert_eq!(
2192            base.security_digest(),
2193            InProcessConfig::default().security_digest()
2194        );
2195    }
2196
2197    #[test]
2198    fn environment_is_synthetic_and_secret_free() {
2199        let (_directory, mut filesystem) = filesystem(&[]);
2200        let outcome = InProcessMonty::default()
2201            .execute(
2202                "import os\n(os.getenv('PWD'), os.getenv('UNDECLARED_SECRET', 'missing'))",
2203                &mut filesystem,
2204            )
2205            .expect("synthetic environment should execute");
2206        assert_eq!(
2207            outcome.value,
2208            MontyObject::Tuple(vec![
2209                MontyObject::String("/workspace".to_owned()),
2210                MontyObject::String("missing".to_owned()),
2211            ])
2212        );
2213    }
2214
2215    #[test]
2216    fn caught_secret_read_never_reaches_vfs_and_forces_final_deny() {
2217        let (_directory, mut filesystem) = filesystem(&[(".env", b"TOKEN=host-secret\n")]);
2218        let outcome = InProcessMonty::default()
2219            .execute(
2220                r"
2221from pathlib import Path
2222try:
2223    Path('/workspace/.env').read_text()
2224except PermissionError:
2225    Path('/workspace/safe.txt').write_text('continued')
2226'done'
2227",
2228                &mut filesystem,
2229            )
2230            .expect("sandboxed code may catch the policy exception");
2231
2232        assert_eq!(outcome.value, MontyObject::String("done".to_owned()));
2233        assert_eq!(outcome.stats.read_bytes, 0);
2234        assert_eq!(outcome.stats.denied_accesses, 1);
2235        assert_eq!(outcome.denied_accesses[0].path.as_str(), ".env");
2236        assert!(
2237            !filesystem
2238                .read_set()
2239                .contains_key(&VPath::parse(".env").unwrap())
2240        );
2241
2242        let diff = filesystem.canonical_diff().unwrap();
2243        let decision = TransactionPolicy::default().evaluate(PolicyInput {
2244            diff: &diff,
2245            effects: filesystem.effects(),
2246            denied_accesses: &outcome.denied_accesses,
2247            base_node_count: 2,
2248        });
2249        assert!(matches!(
2250            decision,
2251            PolicyDecision::Deny(manifest)
2252                if matches!(manifest.reason, DenyReason::ProtectedAccessAttempt(_))
2253        ));
2254    }
2255
2256    #[test]
2257    fn protected_directory_contents_are_hidden_from_listing_and_direct_reads() {
2258        let directory = TestDirectory::new();
2259        let store = BlobStore::open(directory.path()).expect("blob store should open");
2260        let mut builder = SnapshotBuilder::new(store);
2261        builder
2262            .add_directory(VPath::parse(".env").unwrap(), 0o755)
2263            .unwrap();
2264        builder
2265            .add_file(VPath::parse(".env/token").unwrap(), b"host-secret", 0o600)
2266            .unwrap();
2267        builder
2268            .add_file(VPath::parse("safe.txt").unwrap(), b"safe", 0o644)
2269            .unwrap();
2270        let mut filesystem = VirtualFs::new(builder.build().unwrap());
2271
2272        let outcome = InProcessMonty::default()
2273            .execute(
2274                r"
2275from pathlib import Path
2276visible = list(Path('/workspace').iterdir())
2277try:
2278    Path('/workspace/.env/token').read_text()
2279except PermissionError:
2280    pass
2281visible
2282",
2283                &mut filesystem,
2284            )
2285            .expect("protected direct access may be caught without revealing the listing");
2286
2287        assert_eq!(
2288            outcome.value,
2289            MontyObject::List(vec![MontyObject::Path("/workspace/safe.txt".to_owned())])
2290        );
2291        assert_eq!(outcome.stats.read_bytes, 0);
2292        assert_eq!(outcome.denied_accesses.len(), 1);
2293        assert_eq!(outcome.denied_accesses[0].path.as_str(), ".env/token");
2294    }
2295
2296    #[test]
2297    fn recursive_mkdir_authorizes_every_parent_before_virtual_mutation() {
2298        let (_directory, mut filesystem) = filesystem(&[]);
2299        let policy = CallPolicy::new(vec![
2300            vsh_policy::ProtectedRule::new("blocked", vsh_policy::AccessSet::ALL).unwrap(),
2301        ]);
2302        let config = InProcessConfig::default().with_call_policy(policy);
2303        let outcome = InProcessMonty::new(config)
2304            .execute(
2305                r"
2306from pathlib import Path
2307try:
2308    Path('/workspace/blocked/child').mkdir(parents=True)
2309except PermissionError:
2310    pass
2311'contained'
2312",
2313                &mut filesystem,
2314            )
2315            .expect("the protected parent denial may be caught by sandboxed code");
2316
2317        assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2318        assert_eq!(outcome.denied_accesses[0].path.as_str(), "blocked");
2319        assert!(filesystem.canonical_diff().unwrap().is_empty());
2320    }
2321
2322    #[test]
2323    fn caught_vsh_tool_denial_is_retained_for_the_final_policy() {
2324        let (_directory, mut filesystem) = filesystem(&[]);
2325        let policy = CallPolicy::new(vec![
2326            vsh_policy::ProtectedRule::new("blocked", vsh_policy::AccessSet::ALL).unwrap(),
2327        ]);
2328        let config = InProcessConfig::default().with_call_policy(policy);
2329        let outcome = InProcessMonty::new(config)
2330            .execute(
2331                r"
2332try:
2333    vsh_write('/workspace/blocked', 'secret')
2334except PermissionError:
2335    pass
2336'contained'
2337",
2338                &mut filesystem,
2339            )
2340            .expect("sandboxed code may catch a VSH tool policy exception");
2341
2342        assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2343        assert_eq!(outcome.stats.denied_accesses, 1);
2344        assert_eq!(outcome.denied_accesses[0].path.as_str(), "blocked");
2345        assert!(filesystem.canonical_diff().unwrap().is_empty());
2346
2347        let diff = filesystem.canonical_diff().unwrap();
2348        let decision = TransactionPolicy::default().evaluate(PolicyInput {
2349            diff: &diff,
2350            effects: filesystem.effects(),
2351            denied_accesses: &outcome.denied_accesses,
2352            base_node_count: 1,
2353        });
2354        assert!(matches!(
2355            decision,
2356            PolicyDecision::Deny(manifest)
2357                if matches!(manifest.reason, DenyReason::ProtectedAccessAttempt(_))
2358        ));
2359    }
2360
2361    #[test]
2362    fn direct_vsh_search_retains_a_content_only_policy_denial() {
2363        let (_directory, mut filesystem) = filesystem(&[("secret.txt", b"needle")]);
2364        let policy = CallPolicy::new(vec![
2365            vsh_policy::ProtectedRule::new("secret.txt", vsh_policy::AccessSet::CONTENT_READ)
2366                .unwrap(),
2367        ]);
2368        let config = InProcessConfig::default().with_call_policy(policy);
2369        let outcome = InProcessMonty::new(config)
2370            .execute(
2371                r"
2372try:
2373    vsh_search('needle', path='/workspace/secret.txt')
2374except PermissionError:
2375    pass
2376'contained'
2377",
2378                &mut filesystem,
2379            )
2380            .expect("sandboxed code may catch a direct search policy exception");
2381
2382        assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2383        assert_eq!(outcome.stats.denied_accesses, 1);
2384        assert_eq!(outcome.stats.read_bytes, 0);
2385        assert_eq!(outcome.denied_accesses[0].path.as_str(), "secret.txt");
2386    }
2387
2388    #[test]
2389    fn recursive_vsh_remove_preflights_the_entire_tree_before_mutation() {
2390        let directory = TestDirectory::new();
2391        let store = BlobStore::open(directory.path()).expect("blob store should open");
2392        let mut builder = SnapshotBuilder::new(store);
2393        builder
2394            .add_directory(VPath::parse("tree").unwrap(), 0o755)
2395            .unwrap();
2396        builder
2397            .add_directory(VPath::parse("tree/blocked").unwrap(), 0o755)
2398            .unwrap();
2399        builder
2400            .add_file(
2401                VPath::parse("tree/blocked/secret.txt").unwrap(),
2402                b"secret",
2403                0o600,
2404            )
2405            .unwrap();
2406        builder
2407            .add_file(VPath::parse("tree/safe.txt").unwrap(), b"safe", 0o644)
2408            .unwrap();
2409        let mut filesystem = VirtualFs::new(builder.build().unwrap());
2410        let policy = CallPolicy::new(vec![
2411            vsh_policy::ProtectedRule::new("tree/blocked", vsh_policy::AccessSet::ALL).unwrap(),
2412        ]);
2413        let config = InProcessConfig::default().with_call_policy(policy);
2414        let outcome = InProcessMonty::new(config)
2415            .execute(
2416                r"
2417try:
2418    vsh_remove('/workspace/tree', recursive=True)
2419except PermissionError:
2420    pass
2421'contained'
2422",
2423                &mut filesystem,
2424            )
2425            .expect("sandboxed code may catch a recursive-delete policy exception");
2426
2427        assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2428        assert_eq!(outcome.stats.denied_accesses, 1);
2429        assert_eq!(outcome.denied_accesses[0].path.as_str(), "tree/blocked");
2430        assert!(filesystem.canonical_diff().unwrap().is_empty());
2431        assert!(filesystem.exists(&VPath::parse("tree/safe.txt").unwrap()));
2432        assert!(filesystem.exists(&VPath::parse("tree/blocked/secret.txt").unwrap()));
2433    }
2434
2435    #[test]
2436    fn python_result_validation_rejects_unprojectable_types_and_depth() {
2437        let nested_type = MontyObject::List(vec![MontyObject::Type(MontyType::Function)]);
2438        assert_eq!(
2439            validate_result_compatibility(&nested_type, ResultCompatibility::Python),
2440            Err(ResultCompatibilityError::TypeObject {
2441                name: "function".to_owned(),
2442            })
2443        );
2444        assert!(validate_result_compatibility(&nested_type, ResultCompatibility::Native).is_ok());
2445        assert!(
2446            validate_result_compatibility(
2447                &MontyObject::Type(MontyType::Int),
2448                ResultCompatibility::Python,
2449            )
2450            .is_ok()
2451        );
2452
2453        let mut too_deep = MontyObject::None;
2454        for _ in 0..200 {
2455            too_deep = MontyObject::List(vec![too_deep]);
2456        }
2457        assert_eq!(
2458            validate_result_compatibility(&too_deep, ResultCompatibility::Python),
2459            Err(ResultCompatibilityError::Depth {
2460                limit: 200,
2461                attempted: 201,
2462            })
2463        );
2464    }
2465
2466    #[test]
2467    fn mkdir_parents_and_open_append_use_only_virtual_state() {
2468        let (_directory, mut filesystem) = filesystem(&[]);
2469        let outcome = InProcessMonty::default()
2470            .execute(
2471                r"
2472from pathlib import Path
2473Path('/workspace/a/b').mkdir(parents=True)
2474with open('/workspace/a/b/value.txt', 'a') as handle:
2475    handle.write('one')
2476with open('/workspace/a/b/value.txt', 'a') as handle:
2477    handle.write('two')
2478Path('/workspace/a/b/value.txt').read_text()
2479",
2480                &mut filesystem,
2481            )
2482            .expect("open/append flow should execute");
2483        assert_eq!(outcome.value, MontyObject::String("onetwo".to_owned()));
2484        assert_eq!(outcome.stats.write_bytes, 6);
2485    }
2486
2487    #[test]
2488    fn public_path_and_limit_errors_keep_distinct_bounded_diagnostics() {
2489        let root_errors = [
2490            VirtualRootError::NotAbsolute,
2491            VirtualRootError::ParentComponent,
2492            VirtualRootError::NulByte,
2493            VirtualRootError::PlatformSeparator,
2494            VirtualRootError::PlatformPrefix,
2495        ];
2496        assert_eq!(
2497            root_errors
2498                .map(|error| error.to_string())
2499                .into_iter()
2500                .collect::<std::collections::BTreeSet<_>>()
2501                .len(),
2502            root_errors.len()
2503        );
2504
2505        let path_errors = [
2506            VirtualPathError::Empty,
2507            VirtualPathError::NulByte,
2508            VirtualPathError::EscapesAbsoluteRoot,
2509            VirtualPathError::OutsideRoot,
2510            VirtualPathError::InvalidRelative(VPath::parse("").unwrap_err()),
2511        ];
2512        for error in path_errors {
2513            assert!(!error.to_string().is_empty());
2514            assert_eq!(
2515                Error::source(&error).is_some(),
2516                matches!(error, VirtualPathError::InvalidRelative(_))
2517            );
2518        }
2519
2520        let limits = [
2521            ExecutionLimitExceeded::ProgramBytes {
2522                limit: 1,
2523                attempted: 2,
2524            },
2525            ExecutionLimitExceeded::OsCalls {
2526                limit: 1,
2527                attempted: 2,
2528            },
2529            ExecutionLimitExceeded::ReadBytes {
2530                limit: 1,
2531                attempted: 2,
2532            },
2533            ExecutionLimitExceeded::WriteBytes {
2534                limit: 1,
2535                attempted: 2,
2536            },
2537            ExecutionLimitExceeded::ReadCallBytes {
2538                limit: 1,
2539                attempted: 2,
2540            },
2541            ExecutionLimitExceeded::WriteCallBytes {
2542                limit: 1,
2543                attempted: 2,
2544            },
2545            ExecutionLimitExceeded::PathBytes {
2546                limit: 1,
2547                attempted: 2,
2548            },
2549            ExecutionLimitExceeded::DirectoryEntries {
2550                limit: 1,
2551                attempted: 2,
2552            },
2553            ExecutionLimitExceeded::OutputBytes {
2554                limit: 1,
2555                attempted: 2,
2556            },
2557            ExecutionLimitExceeded::ResultBytes {
2558                limit: 1,
2559                attempted: 2,
2560            },
2561            ExecutionLimitExceeded::ExceptionBytes {
2562                limit: 1,
2563                attempted: 2,
2564            },
2565        ];
2566        assert_eq!(
2567            limits
2568                .map(|error| error.to_string())
2569                .into_iter()
2570                .collect::<std::collections::BTreeSet<_>>()
2571                .len(),
2572            limits.len()
2573        );
2574    }
2575
2576    #[test]
2577    fn public_result_and_execution_errors_keep_distinct_diagnostics() {
2578        let compatibility_errors = [
2579            ResultCompatibilityError::Depth {
2580                limit: 1,
2581                attempted: 2,
2582            },
2583            ResultCompatibilityError::TypeObject {
2584                name: "unsupported".to_owned(),
2585            },
2586        ];
2587        assert_ne!(
2588            compatibility_errors[0].to_string(),
2589            compatibility_errors[1].to_string()
2590        );
2591
2592        let execution_errors = [
2593            ExecutionError::Limit(Box::new(ExecutionLimitExceeded::OsCalls {
2594                limit: 1,
2595                attempted: 2,
2596            })),
2597            ExecutionError::InternalVfs(Box::new(VfsError::RootMutation)),
2598            ExecutionError::UnsupportedSuspension {
2599                kind: "call",
2600                name: Some("name".to_owned()),
2601            },
2602            ExecutionError::UnsupportedSuspension {
2603                kind: "call",
2604                name: None,
2605            },
2606            ExecutionError::Worker(Box::new(WorkerFailure {
2607                kind: WorkerFailureKind::Protocol,
2608                detail: "bounded".to_owned(),
2609            })),
2610        ];
2611        for error in execution_errors {
2612            assert!(!error.to_string().is_empty());
2613        }
2614    }
2615}