From 74bf8927ca1b61f0319c06dffdb1594c7c54ce27 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 14 Aug 2026 09:22:08 +0000 Subject: [PATCH] feat(python): upgrade Monty to 0.0.21 and drop the jiter patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot split this upgrade into two PRs (#2296 monty-types, #2297 monty). Either one alone leaves two `monty-types` versions in the graph, so its result types stop unifying and ~22 CI checks go red. Bumping both together reduces that to two real API breaks: * `ResourceLimits::new()` -> `default()`, and `max_recursion_depth` now takes `usize` rather than `Option`. * `ResourceTracker` changed from a host-implementable trait into a concrete struct and `LimitedTracker` was removed, so the `BudgetTracker` wrapper that charged the shared `ExecutionBudget` from inside the VM's own allocation/statement checkpoints no longer has anything to hook into, and 0.0.21 ships no replacement hook. Python now drives the shared budget the same way the TypeScript builtin always has: an up-front charge (input bytes, code size, and a reserve proportional to the VM memory ceiling) plus per-round-trip charging in the start/resume loop. Containment is unchanged, because it was never the tracker's job — `max_duration` is clamped to the caller's remaining execution deadline before the VM starts, and Monty's own tracker still enforces that duration plus `max_memory` and the recursion ceiling synchronously. Only the finer-grained work-unit accounting for a script that never re-enters the host loop is lost; the up-front reserve approximates it. monty 0.0.21 tracks the published jiter 0.16.0, which is already on pyo3 0.29 — the exact condition the `[patch.crates-io]` jiter git pin documented for its own removal — so that pin is dropped. Adds a regression test for the charging path that now carries the load, so removing host-loop charging fails loudly rather than silently. --- Cargo.lock | 58 +++++++-- Cargo.toml | 30 ++--- crates/bashkit-python/Cargo.toml | 2 +- crates/bashkit/Cargo.toml | 4 +- crates/bashkit/src/builtins/python.rs | 121 +++++------------- .../integration/execution_budget_tests.rs | 27 ++++ knowledge/runtimes/python-builtin.md | 22 +++- supply-chain/config.toml | 6 +- 8 files changed, 139 insertions(+), 131 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 164ab7b2e..08576d3a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,7 +961,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2525,8 +2525,9 @@ dependencies = [ [[package]] name = "jiter" -version = "0.15.0" -source = "git+https://github.com/pydantic/jiter?rev=6d57715e01ec78859c62fc5447073c0b5902de39#6d57715e01ec78859c62fc5447073c0b5902de39" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "820ddcacd75c9782308d3fa594f3885630ea3f49adba78a8a29abdfe4b81630c" dependencies = [ "ahash", "bitvec", @@ -2916,18 +2917,20 @@ dependencies = [ [[package]] name = "monty" -version = "0.0.19" +version = "0.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b15149448f6034f1bd581638fdf821753b0182a4c5eaf13e4cfd92f95d966b" +checksum = "db44565f603ada5dd98bd580623cd9ce299c8b823bd7195c654826fa536b4d48" dependencies = [ "ahash", "chrono", "fancy-regex 0.17.0", "hashbrown 0.16.1", + "indexmap", "itertools 0.14.0", "itoa", "jiter", "libm", + "memchr", "monty-macros", "monty-types", "num-bigint", @@ -2935,8 +2938,10 @@ dependencies = [ "num-traits", "postcard", "ruff_python_ast", + "ruff_python_codegen", "ruff_python_parser", "ruff_python_stdlib", + "ruff_source_file", "ruff_text_size", "serde", "smallvec", @@ -2949,9 +2954,9 @@ dependencies = [ [[package]] name = "monty-macros" -version = "0.0.19" +version = "0.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b6c93dbe4b43469c6d2ece3732053f3fc7af278cf71045ed59c7b6da381530" +checksum = "40ed428288161f63d2bec7929438563fe31390f08e014e58ce20be96d7ef82ee" dependencies = [ "proc-macro2", "quote", @@ -2960,9 +2965,9 @@ dependencies = [ [[package]] name = "monty-types" -version = "0.0.19" +version = "0.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8988fa8d9902432d5e685a7bb97c84844022dd9560e796c00b119a4c27519b" +checksum = "00b4b3318107fee36c5eee05c52f16f6565a63aa29d04f8608ebc221e3fe5dd8" dependencies = [ "chrono", "monty-macros", @@ -4327,6 +4332,31 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ruff_python_codegen" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1902d82ae0745be9d3d12a1aee4429acff987c768710dc38a6521eab05990ca0" +dependencies = [ + "ruff_python_ast", + "ruff_python_literal", + "ruff_python_parser", + "ruff_source_file", + "ruff_text_size", +] + +[[package]] +name = "ruff_python_literal" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3e1f868fa58adc12d1fa3ce1b29c6098aa4b101739d9589cbde9fec239af59" +dependencies = [ + "bitflags 2.13.1", + "icu_properties", + "itertools 0.15.0", + "ruff_python_ast", +] + [[package]] name = "ruff_python_parser" version = "0.0.3" @@ -4521,7 +4551,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4578,7 +4608,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5314,7 +5344,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6125,7 +6155,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 91e8b53ee..9b8e8ca09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,22 +164,18 @@ inherits = "release" lto = "fat" codegen-units = 1 -# Important decision (security): pin `jiter` to upstream main so the whole -# workspace can run pyo3 0.29, which fixes GHSA-36hh-v3qg-5jq4 (high, OOB read -# in PyList/PyTuple iterators) and GHSA-chgr-c6px-7xpp (missing `Sync` bound) -# in the bashkit-python extension. +# Important decision (security): the workspace runs pyo3 0.29, which fixes +# GHSA-36hh-v3qg-5jq4 (high, OOB read in PyList/PyTuple iterators) and +# GHSA-chgr-c6px-7xpp (missing `Sync` bound) in the bashkit-python extension. # -# Blocker: `monty 0.0.19` -> `jiter ^0.15.0` and jiter 0.15.0 declares an -# optional `pyo3 = "^0.28.2"` (jiter 0.16.0 moved to pyo3 0.29 but is outside -# monty's range). -# Although jiter's `python` feature is never activated here (monty only enables -# `num-bigint`), the weak `pyo3?/num-bigint` reference plus pyo3-ffi's -# `links = "python"` global uniqueness force the resolver to honour jiter's -# 0.28 constraint, pinning the whole graph below pyo3 0.29. +# This used to require a `[patch.crates-io]` pin of `jiter` to upstream main: +# `monty 0.0.19` -> `jiter ^0.15.0`, and jiter 0.15.0 declared an optional +# `pyo3 = "^0.28.2"` that dragged the whole graph below pyo3 0.29 (the weak +# `pyo3?/num-bigint` reference plus pyo3-ffi's `links = "python"` global +# uniqueness force the resolver to honour it even though jiter's `python` +# feature is never activated here). # -# jiter `main` is still version 0.15.0 (semver-compatible with monty's -# `^0.15.0`) but already bumped its pyo3 dependency to 0.29, so this patch -# unblocks the upgrade without a fork. Drop it once monty ships a release that -# tracks a published jiter with pyo3 0.29. -[patch.crates-io] -jiter = { git = "https://github.com/pydantic/jiter", rev = "6d57715e01ec78859c62fc5447073c0b5902de39" } +# `monty 0.0.21` tracks the published `jiter 0.16.0`, which is already on +# pyo3 0.29, so the patch is no longer needed and has been dropped. Keep +# monty and monty-types on the same version — a split leaves two +# `monty-types` in the graph and its result types stop unifying. diff --git a/crates/bashkit-python/Cargo.toml b/crates/bashkit-python/Cargo.toml index 751cb1297..7be08267a 100644 --- a/crates/bashkit-python/Cargo.toml +++ b/crates/bashkit-python/Cargo.toml @@ -24,7 +24,7 @@ serde_json = { workspace = true } # Big-integer support for py_to_monty BigInt extraction. # Pinned to 0.4.x: py_to_monty parses Python ints into `num_bigint::BigInt` and -# hands them to `MontyObject::BigInt`, and monty (0.0.19) depends on +# hands them to `MontyObject::BigInt`, and monty (0.0.21) depends on # num-bigint 0.4. Bumping to 0.5 makes the two BigInt types mismatch and fails # to compile, so this must track whatever major version bashkit core / monty use. num-bigint = "^0.4.6" diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 272d5dff5..0d50e773b 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -106,8 +106,8 @@ os_display = "0.1.3" # Registry dep since monty 0.0.19 — the first version published to crates.io. # Keeps `python` usable for downstream crates and lets the publish workflow # ship the feature instead of stripping it. -monty = { version = "0.0.19", optional = true } -monty-types = { version = "0.0.19", optional = true } +monty = { version = "0.0.21", optional = true } +monty-types = { version = "0.0.21", optional = true } # Embedded TypeScript interpreter (optional) zapcode-core = { version = "1.5.1", optional = true } diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index 2f04b1c9b..01fc7c0ce 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -23,11 +23,10 @@ use async_trait::async_trait; use chrono::{Datelike, Timelike}; use monty::{MontyRun, RunProgress}; use monty_types::{ - CompileOptions, ExcType, ExtFunctionResult, FileMode, LimitedTracker, MontyDate, MontyDateTime, - MontyException, MontyFileHandle, MontyObject, NameLookupResult, OsFunctionCall, PrintWriter, - ResourceError, ResourceLimits, ResourceTracker, dir_stat, file_stat, symlink_stat, + CompileOptions, ExcType, ExtFunctionResult, FileMode, MontyDate, MontyDateTime, MontyException, + MontyFileHandle, MontyObject, NameLookupResult, OsFunctionCall, PrintWriter, ResourceLimits, + ResourceTracker, dir_stat, file_stat, symlink_stat, }; -use std::cell::Cell; use std::collections::HashMap; use std::future::Future; use std::path::{Path, PathBuf}; @@ -50,85 +49,28 @@ const DISABLED_STDLIB_MODULES: &[&str] = &["re"]; // sandboxed code cannot fingerprint host clock/timezone state. const VIRTUAL_NOW_UNIX_SECS: i64 = 1_704_067_200; // 2024-01-01T00:00:00Z -/// Bridges Monty's statement/allocation checkpoints into the shared request -/// budget while retaining Monty's own memory/time/recursion ceilings. -#[derive(Debug)] -struct BudgetTracker { - runtime: LimitedTracker, - execution: Option, - vm_checkpoints: Cell, -} - -impl BudgetTracker { - fn budget_error(err: crate::limits::LimitExceeded) -> ResourceError { - // Monty's tracker error type has no host-defined variant. The shared - // budget retains the precise poisoned reason; use an uncatchable - // memory error only to stop the VM at this checkpoint. - let _ = err; - ResourceError::Memory { limit: 0, used: 1 } - } - - fn consume_work(&self) -> std::result::Result<(), ResourceError> { - if let Some(budget) = &self.execution { - budget.consume_work(1).map_err(Self::budget_error)?; - } - Ok(()) - } - - fn check_vm(&self) -> std::result::Result<(), ResourceError> { - if let Some(budget) = &self.execution { - budget.check().map_err(Self::budget_error)?; - let checkpoints = self.vm_checkpoints.get().wrapping_add(1); - self.vm_checkpoints.set(checkpoints); - if checkpoints.is_multiple_of(64) { - budget.consume_work(1).map_err(Self::budget_error)?; - } - } - Ok(()) - } -} - -impl ResourceTracker for BudgetTracker { - fn on_free(&self, get_size: impl FnOnce() -> usize) { - self.runtime.on_free(get_size); - } - - fn check_time(&self) -> std::result::Result<(), ResourceError> { - self.check_vm()?; - self.runtime.check_time() - } - - fn check_recursion_depth( - &self, - current_depth: usize, - ) -> std::result::Result<(), ResourceError> { - self.runtime.check_recursion_depth(current_depth) - } - - fn check_large_result(&self, estimated_bytes: usize) -> std::result::Result<(), ResourceError> { - self.runtime.check_large_result(estimated_bytes) - } - - fn on_grow( - &self, - additional_bytes: impl FnOnce() -> usize, - ) -> std::result::Result<(), ResourceError> { - self.consume_work()?; - self.runtime.on_grow(additional_bytes) - } - - fn gc_interval(&self) -> Option { - self.runtime.gc_interval() - } - - fn on_execution_start(&self) { - self.runtime.on_execution_start(); - } - - fn on_execution_stop(&self) { - self.runtime.on_execution_stop(); - } -} +// Important decision (security): Monty 0.0.21 turned `ResourceTracker` from a +// host-implementable trait into a concrete struct, deleting `LimitedTracker`. +// Bashkit used to wrap it in a `BudgetTracker` that charged the shared +// `ExecutionBudget` from inside the VM's own allocation/statement checkpoints. +// There is no replacement hook in 0.0.21, so that per-checkpoint charging is +// gone and the shared budget is now driven the same way the TypeScript builtin +// (which never had an in-VM hook) drives it: +// +// * up front, before the VM starts — input bytes, code size, and a reserve +// proportional to the VM's independent memory ceiling, so repeated +// invocations cannot each claim a fresh full allowance; +// * per host round-trip in the start/resume loop — one unit per OS call, +// 100 per external function call. +// +// The containment properties that matter are unchanged, because they were +// never the tracker's job: `max_duration` is clamped to the caller's remaining +// execution deadline before the VM starts (see `Builtin::execute` below), and +// Monty's own `ResourceTracker` still enforces that duration plus `max_memory` +// and the recursion ceiling synchronously. A CPU-bound script that never +// re-enters the host loop is therefore still stopped by the clamped deadline; +// what is lost is only the finer-grained *work-unit* accounting for such a +// script, which the up-front reserve approximates. const VIRTUAL_NOW_NANOS: u32 = 123_456_000; // 123456 µs for deterministic microseconds const PYTHON_INPROCESS_OPT_IN_ENV: &str = "BASHKIT_ALLOW_INPROCESS_PYTHON"; @@ -625,16 +567,15 @@ async fn run_python( Err(e) => return Ok(format_exception(e)), }; - let limits = ResourceLimits::new() + let limits = ResourceLimits::default() .max_duration(py_limits.common.max_duration) .max_memory(py_limits.common.max_memory) - .max_recursion_depth(Some(py_limits.common.max_call_depth)); + .max_recursion_depth(py_limits.common.max_call_depth); - let tracker = BudgetTracker { - runtime: LimitedTracker::new(limits), - execution: execution_budget.clone(), - vm_checkpoints: Cell::new(0), - }; + // See the decision note at the top of this file: Monty 0.0.21 dropped the + // host-implementable tracker trait, so the shared budget is charged up + // front and per host round-trip instead of per VM checkpoint. + let tracker = ResourceTracker::new(limits); // Important security decision: cap collected print output at the same // memory budget as the VM heap. Monty 0.0.19 added a byte cap on // `PrintWriter::CollectString` because a `while True: print(...)` loop diff --git a/crates/bashkit/tests/integration/execution_budget_tests.rs b/crates/bashkit/tests/integration/execution_budget_tests.rs index 6050f6ed7..16b225cbf 100644 --- a/crates/bashkit/tests/integration/execution_budget_tests.rs +++ b/crates/bashkit/tests/integration/execution_budget_tests.rs @@ -185,6 +185,33 @@ async fn repeated_python_entries_share_runtime_admission_budget() { ); } +#[cfg(feature = "python")] +#[tokio::test] +/// TM-DOS-096: a VFS-heavy Python loop must still exhaust shared work units. +/// +/// Monty 0.0.21 removed the host-implementable `ResourceTracker` trait, so +/// Bashkit no longer charges the budget from inside the VM's own allocation +/// checkpoints; the start/resume loop's per-round-trip charging is what +/// remains. Each `open()` suspends the VM into an OsCall, so a loop of them +/// must still drain the budget — this fails if that charging is ever dropped. +async fn python_vfs_round_trips_consume_shared_work_budget() { + let limits = ExecutionLimits::new() + .max_work_units(2_000_000) + .max_aggregate_input_bytes(100_000); + let mut bash = Bash::builder() + .limits(limits) + .python() + .env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1") + .build(); + + assert_budget_exhausted( + bash.exec( + "python -c '\nfor i in range(100000):\n f = open(\"/tmp/x\", \"w\")\n f.write(\"a\")\n f.close()\n'", + ) + .await, + ); +} + #[cfg(feature = "typescript")] #[tokio::test] /// TM-DOS-096: separate TypeScript entries cannot refresh allocation fuel. diff --git a/knowledge/runtimes/python-builtin.md b/knowledge/runtimes/python-builtin.md index 9e20e15f2..901466a3b 100644 --- a/knowledge/runtimes/python-builtin.md +++ b/knowledge/runtimes/python-builtin.md @@ -72,12 +72,26 @@ allocation-count knob — Monty removed `max_allocations` in 0.0.19. Each Python entry also consumes the request-scoped `ExecutionBudget`: source bytes are charged as aggregate input, configured memory contributes a -conservative non-refundable admission reservation, and Monty's time/allocation -checkpoints consume shared work units. VFS pauses and external host calls keep -the same budget clone. Re-entering Python from a later command, substitution, -or pipeline stage therefore cannot obtain fresh aggregate fuel; Monty's limits +conservative non-refundable admission reservation, and each host round-trip in +the start/resume loop consumes shared work units (one per VFS pause, 100 per +external function call). VFS pauses and external host calls keep the same +budget clone. Re-entering Python from a later command, substitution, or +pipeline stage therefore cannot obtain fresh aggregate fuel; Monty's limits remain independently enforced. +Monty 0.0.21 replaced the host-implementable `ResourceTracker` trait (and the +`LimitedTracker` impl) with a concrete struct, so Bashkit can no longer charge +the shared budget from *inside* the VM's allocation/statement checkpoints; the +former `BudgetTracker` wrapper is gone and no replacement hook exists upstream. +This matches how the TypeScript builtin has always driven the budget. Nothing +that bounds a runaway script changed: before the VM starts, Monty's +`max_duration` is clamped to the caller's remaining execution deadline, and +Monty's own tracker still enforces that duration plus `max_memory` and the +recursion ceiling synchronously. A CPU-bound script that never re-enters the +host loop is therefore still stopped by the clamped deadline — only the +finer-grained work-unit accounting for such a script is lost, and the up-front +memory-proportional reservation approximates it. + Since Monty 0.0.4 the parser also enforces a nesting-depth limit (200 release / 35 debug) against stack overflow from deeply nested expressions. diff --git a/supply-chain/config.toml b/supply-chain/config.toml index d6ff14dd0..94d400944 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -1143,15 +1143,15 @@ version = "0.2.3" criteria = "safe-to-deploy" [[exemptions.monty]] -version = "0.0.19" +version = "0.0.21" criteria = "safe-to-deploy" [[exemptions.monty-macros]] -version = "0.0.19" +version = "0.0.21" criteria = "safe-to-deploy" [[exemptions.monty-types]] -version = "0.0.19" +version = "0.0.21" criteria = "safe-to-deploy" [[exemptions.napi]]