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