From d819786aee7a824cefcd608bf533336918e923eb Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 25 Jul 2026 03:46:46 +0000 Subject: [PATCH 1/2] chore(deps): move monty to crates.io 0.0.19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monty published 0.0.19 to crates.io, so the embedded Python interpreter is no longer a git dependency. The `python` feature now works from a registry install, and the publish workflow stops stripping monty, the `python` feature, and the python examples out of every workspace manifest before `cargo publish` (verified with `cargo publish -p bashkit --dry-run`). Two upstream API changes come with the bump: - `ResourceLimits::max_allocations` is gone (pydantic/monty#611). The knob moves out of the shared `RuntimeLimits` onto `TypeScriptLimits`, which is the only runtime that still enforces one — keeping it on `PythonLimits` would hand callers a security knob that silently does nothing. Python allocation bombs are now contained by `max_memory` / `max_duration`. - `PrintWriter::CollectString` takes a byte cap (pydantic/monty#558), since a print loop grows the host buffer without touching the VM heap. Wired to `max_memory` so one number bounds both heap and collected output. Specs, threat model, and rustdoc guides updated to match; deny.toml drops the monty/ruff git allowances (jiter's patch pin stays — monty 0.0.19 still requires jiter ^0.15.0, whose published release is pyo3 0.28-only). Claude-Session: https://claude.ai/code/session_01CA9FHReY9cMTzeTLrkeU4Q --- .github/dependabot.yml | 8 +- .github/workflows/publish.yml | 42 +--------- Cargo.lock | 15 ++-- Cargo.toml | 4 +- crates/bashkit-python/Cargo.toml | 2 +- crates/bashkit/Cargo.toml | 11 ++- crates/bashkit/docs/python.md | 6 +- crates/bashkit/docs/threat-model.md | 8 +- crates/bashkit/src/builtins/python.rs | 50 ++++++------ crates/bashkit/src/builtins/runtime_limits.rs | 31 +++----- crates/bashkit/src/builtins/typescript.rs | 22 ++++-- crates/bashkit/src/lib.rs | 4 +- .../integration/python_integration_tests.rs | 4 +- .../integration/python_security_tests.rs | 78 ++++++++++++++----- deny.toml | 10 +-- docs/cli.md | 5 +- docs/start-rust.md | 10 +-- specs/python-builtin.md | 12 ++- specs/release-process.md | 5 +- specs/threat-model.md | 4 +- 20 files changed, 171 insertions(+), 160 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 97849129b..324321563 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,13 +17,19 @@ updates: # rather than landing inside an aggregated `chore(deps)` PR. - "turso_core" - "turso_*" + # monty is pre-1.0 and breaks its embedding API on most 0.0.x bumps + # (0.0.19 dropped `max_allocations` and added a print-collect cap). + # Keep it standalone so the builtin rewrite each bump needs is + # reviewed on its own, not buried in an aggregated `chore(deps)` PR. + - "monty" + - "monty-*" # Surface turso bumps individually with a label that flags them for # extra scrutiny. The `groups` section above already excludes them # from the aggregated PR; no additional config needed here for # standalone delivery. ignore: # num-bigint is hard-pinned to 0.4.x in crates/bashkit-python: monty - # (git v0.0.18) depends on num-bigint 0.4, and py_to_monty hands a + # (0.0.19) depends on num-bigint 0.4, and py_to_monty hands a # `num_bigint::BigInt` to `MontyObject::BigInt`. A 0.5 bump makes the two # BigInt types mismatch and fails to compile. Block major/minor jumps # (0.4 -> 0.5) while still allowing 0.4.x patch updates, until monty diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9113a4176..1e0214c0d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,36 +58,8 @@ jobs: fi echo "Version verified: $TAG_VERSION" - - name: Strip git-only dependencies for publishing - # monty is a git dep (not yet on crates.io) — remove before publish. - # Must strip from ALL workspace crates because cargo resolves the - # whole workspace when publishing a single crate. - run: | - # --- bashkit core: remove monty dep and python feature --- - TOML=crates/bashkit/Cargo.toml - sed -i '/^monty = .*/d' "$TOML" - sed -i '/^python = \["dep:monty"\]/d' "$TOML" - sed -i '/^\[\[example\]\]/{N;N;/python_scripts/d}' "$TOML" - sed -i '/^\[\[example\]\]/{N;N;/python_external_functions/d}' "$TOML" - - # --- bashkit-cli: remove python feature --- - CLI_TOML=crates/bashkit-cli/Cargo.toml - sed -i '/^python = \["bashkit\/python"\]/d' "$CLI_TOML" - sed -i 's/, "python"//; s/"python", //; s/\["python"\]/[]/' "$CLI_TOML" - - # --- bashkit-js: remove python from features list --- - JS_TOML=crates/bashkit-js/Cargo.toml - sed -i 's/, "python"//; s/"python", //' "$JS_TOML" - - # --- bashkit-python: remove python from features list --- - PY_TOML=crates/bashkit-python/Cargo.toml - sed -i 's/, "python"//; s/"python", //' "$PY_TOML" - - echo "=== Verify no python feature references remain ===" - grep -rn '"python"' crates/*/Cargo.toml || echo "Clean — no python feature refs" - - name: Publish bashkit to crates.io - run: cargo publish -p bashkit --allow-dirty + run: cargo publish -p bashkit env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -108,18 +80,8 @@ jobs: - name: Wait for crates.io index update run: sleep 30 - - name: Strip python feature from bashkit-cli - # python feature is stripped from bashkit during publish (monty is git-only) - run: | - TOML=crates/bashkit-cli/Cargo.toml - # Remove python feature and default that references it - sed -i '/^python = \["bashkit\/python"\]/d' "$TOML" - sed -i 's/, "python"//; s/"python", //; s/\["python"\]/[]/' "$TOML" - echo "--- bashkit-cli Cargo.toml after stripping ---" - cat "$TOML" - - name: Publish bashkit-cli to crates.io - run: cargo publish -p bashkit-cli --allow-dirty + run: cargo publish -p bashkit-cli env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 012c2f2ad..ec1ab6f45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2934,8 +2934,9 @@ dependencies = [ [[package]] name = "monty" -version = "0.0.19-beta.6" -source = "git+https://github.com/pydantic/monty?tag=v0.0.19-beta.6#344f91d17919d88ad7de4e13432d73c7c2ed9a4a" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b15149448f6034f1bd581638fdf821753b0182a4c5eaf13e4cfd92f95d966b" dependencies = [ "ahash", "chrono", @@ -2966,8 +2967,9 @@ dependencies = [ [[package]] name = "monty-macros" -version = "0.0.19-beta.6" -source = "git+https://github.com/pydantic/monty?tag=v0.0.19-beta.6#344f91d17919d88ad7de4e13432d73c7c2ed9a4a" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b6c93dbe4b43469c6d2ece3732053f3fc7af278cf71045ed59c7b6da381530" dependencies = [ "proc-macro2", "quote", @@ -2976,8 +2978,9 @@ dependencies = [ [[package]] name = "monty-types" -version = "0.0.19-beta.6" -source = "git+https://github.com/pydantic/monty?tag=v0.0.19-beta.6#344f91d17919d88ad7de4e13432d73c7c2ed9a4a" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8988fa8d9902432d5e685a7bb97c84844022dd9560e796c00b119a4c27519b" dependencies = [ "chrono", "monty-macros", diff --git a/Cargo.toml b/Cargo.toml index cb70a33b0..720369e72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,7 +166,9 @@ codegen-units = 1 # in PyList/PyTuple iterators) and GHSA-chgr-c6px-7xpp (missing `Sync` bound) # in the bashkit-python extension. # -# Blocker: `monty` -> `jiter 0.15.0` declares an optional `pyo3 = "^0.28.2"`. +# 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 diff --git a/crates/bashkit-python/Cargo.toml b/crates/bashkit-python/Cargo.toml index 4e29d6ecb..baff9c523 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 (git tag v0.0.18) depends on +# hands them to `MontyObject::BigInt`, and monty (0.0.19) 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 91aa7e9f6..f7adad95c 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -99,9 +99,12 @@ num-traits = "0.2" unit-prefix = "0.5" os_display = "0.1.3" -# Embedded Python interpreter (optional) -monty = { git = "https://github.com/pydantic/monty", tag = "v0.0.19-beta.6", optional = true } -monty-types = { git = "https://github.com/pydantic/monty", tag = "v0.0.19-beta.6", optional = true } +# Embedded Python interpreter (optional). +# 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 } # Embedded TypeScript interpreter (optional) zapcode-core = { version = "1.5.1", optional = true } @@ -143,7 +146,7 @@ ssh = ["russh"] # Usage: cargo build --features scripted_tool scripted_tool = ["bash_tool"] # Enable python/python3 builtins via embedded Monty interpreter -# Monty is a git dep (not yet on crates.io) — feature unavailable from registry +# Usage: cargo build --features python python = ["dep:monty", "dep:monty-types"] # Enable ts/node/deno/bun builtins via embedded ZapCode TypeScript interpreter # Usage: cargo build --features typescript diff --git a/crates/bashkit/docs/python.md b/crates/bashkit/docs/python.md index 3e6b2137e..b6fbe9a29 100644 --- a/crates/bashkit/docs/python.md +++ b/crates/bashkit/docs/python.md @@ -140,7 +140,6 @@ let bash = Bash::builder() PythonLimits::default() .max_duration(Duration::from_secs(5)) .max_memory(16 * 1024 * 1024) // 16 MB - .max_allocations(100_000) .max_recursion(50) ) .build(); @@ -149,9 +148,8 @@ let bash = Bash::builder() | Limit | Default | Purpose | |-------|---------|---------| -| Allocations | 1,000,000 | Heap allocation cap | | Duration | 30 seconds | Execution timeout | -| Memory | 64 MB | Heap memory cap | +| Memory | 64 MB | Heap memory cap, also caps collected `print` output | | Recursion | 200 | Call stack depth | ## LLM Tool Integration @@ -208,7 +206,7 @@ All Python execution runs in a virtual environment: - **No host filesystem access** — all paths resolve through the VFS - **No network access** — no sockets, HTTP, or DNS - **No process spawning** — no `os.system()`, `subprocess`, or `__import__('os')` -- **Resource limited** — allocation, time, memory, and recursion caps +- **Resource limited** — time, memory (including collected output), and recursion caps - **Path traversal safe** — `../..` is resolved by VFS path normalization See threat IDs TM-PY-001 through TM-PY-029 in the [threat model](./threat-model.md). diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 6f159ec7c..eb6841545 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -616,8 +616,8 @@ Python `pathlib.Path` and `open()` operations are bridged to Bashkit's virtual f | Threat | Attack Example | Mitigation | Status | |--------|---------------|------------|--------| -| Infinite loop (TM-PY-001) | `while True: pass` | Monty time limit (30s) + allocation cap | MITIGATED | -| Memory exhaustion (TM-PY-002) | Large allocation | Monty max_memory (64MB) + max_allocations (1M) | MITIGATED | +| Infinite loop (TM-PY-001) | `while True: pass` | Monty time limit (30s) | MITIGATED | +| Memory exhaustion (TM-PY-002) | Large allocation | Monty max_memory (64MB), also caps collected `print` output | MITIGATED | | Stack overflow (TM-PY-003) | Deep recursion | Monty max_recursion (200) | MITIGATED | | Shell escape (TM-PY-004) | `os.system()` | Monty has no os.system/subprocess | MITIGATED | | Real FS access (TM-PY-005) | `open()` | VFS bridge opens only Bashkit VFS files | MITIGATED | @@ -654,8 +654,8 @@ Python `pathlib.Path` and `open()` operations are bridged to Bashkit's virtual f Python code → Monty VM → OsCall pause → Bashkit VFS bridge → resume ``` -Monty runs directly in the host process. Resource limits (memory, allocations, -time, recursion) are enforced by Monty's own runtime. All VFS operations are +Monty runs directly in the host process. Resource limits (memory, time, +recursion) are enforced by Monty's own runtime. All VFS operations are bridged through the host process — Python code never touches the real filesystem. ### SQLite Security (TM-SQL-*) diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index ea2d984c5..581e99347 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -3,8 +3,8 @@ //! # Direct Integration //! //! Monty runs directly in the host process. No subprocess, no IPC. -//! Resource limits (memory, allocations, time, recursion) are enforced -//! by Monty's own runtime, not by process isolation. +//! Resource limits (memory, time, recursion) are enforced by Monty's own +//! runtime, not by process isolation. //! //! # Overview //! @@ -40,7 +40,7 @@ use crate::fs::{FileSystem, FileType}; use crate::interpreter::ExecResult; /// Python's default recursion-depth cap. The other VM limits (duration, -/// memory, allocations) default via [`RuntimeLimits::default`]. +/// memory) default via [`RuntimeLimits::default`]. const DEFAULT_MAX_RECURSION: usize = 200; // Security hard-stop: catastrophic regex backtracking can bypass cooperative // interpreter budget checks, so disable regex stdlib module in untrusted code. @@ -78,11 +78,14 @@ fn python_inprocess_enabled(ctx: &Context<'_>) -> bool { /// Resource limits for the embedded Python (Monty) interpreter. /// /// Use the builder pattern to customize, or `Default` for the standard virtual execution limits: -/// - 1,000,000 allocations /// - 30 second timeout -/// - 64 MB memory +/// - 64 MB memory (also caps collected `print` output) /// - 200 recursion depth /// +/// There is no allocation-count knob: Monty removed `max_allocations` from its +/// resource limits in 0.0.19, so allocation bombs are contained by `max_memory` +/// and `max_duration` instead. +/// /// # Example /// /// ```rust,ignore @@ -99,7 +102,7 @@ fn python_inprocess_enabled(ctx: &Context<'_>) -> bool { /// setters below configure them directly. #[derive(Debug, Clone)] pub struct PythonLimits { - /// Shared VM resource limits (duration, memory, allocations, call depth). + /// Shared VM resource limits (duration, memory, call depth). pub common: RuntimeLimits, } @@ -116,13 +119,6 @@ impl Default for PythonLimits { } impl PythonLimits { - /// Set max heap allocations. - #[must_use] - pub fn max_allocations(mut self, n: usize) -> Self { - self.common.max_allocations = n; - self - } - /// Set max execution duration. #[must_use] pub fn max_duration(mut self, d: Duration) -> Self { @@ -493,12 +489,19 @@ async fn run_python( }; let limits = ResourceLimits::new() - .max_allocations(py_limits.common.max_allocations) .max_duration(py_limits.common.max_duration) .max_memory(py_limits.common.max_memory) .max_recursion_depth(Some(py_limits.common.max_call_depth)); let tracker = LimitedTracker::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 + // grows the *host* buffer without touching the VM heap, so an uncapped + // collector OOMs the host while `max_memory` stays happy. Reusing + // `max_memory` keeps one number to reason about: tightening the memory + // limit tightens the output cap with it. + let print_cap = Some(py_limits.common.max_memory); // Important security decision: cap awaited host callbacks with the same wall-clock // budget as Monty so external functions cannot pin execution between VM steps. let python_deadline = Instant::now().checked_add(py_limits.common.max_duration); @@ -507,7 +510,11 @@ async fn run_python( // PrintWriter::CollectString is not Send, so we scope it to avoid holding across .await. let (mut progress, mut buf) = { let mut buf = String::new(); - match runner.start(vec![], tracker, PrintWriter::CollectString(&mut buf)) { + match runner.start( + vec![], + tracker, + PrintWriter::CollectString(&mut buf, print_cap), + ) { Ok(p) => (p, buf), Err(e) => { return Ok(format_exception_with_output(e, &buf)); @@ -520,7 +527,7 @@ async fn run_python( RunProgress::OsCall(os_call) => { let function_call = os_call.function_call.clone(); let result = handle_os_call(function_call, &fs, cwd, env).await; - match os_call.resume(result, PrintWriter::CollectString(&mut buf)) { + match os_call.resume(result, PrintWriter::CollectString(&mut buf, print_cap)) { Ok(next) => { progress = next; } @@ -549,7 +556,7 @@ async fn run_python( )) }; - match call.resume(result, PrintWriter::CollectString(&mut buf)) { + match call.resume(result, PrintWriter::CollectString(&mut buf, print_cap)) { Ok(next) => { progress = next; } @@ -576,7 +583,7 @@ async fn run_python( NameLookupResult::Undefined }; - match lookup.resume(result, PrintWriter::CollectString(&mut buf)) { + match lookup.resume(result, PrintWriter::CollectString(&mut buf, print_cap)) { Ok(next) => { progress = next; } @@ -1491,9 +1498,7 @@ mod tests { #[tokio::test] async fn test_custom_limits_generous() { // Generous limits should succeed - let limits = PythonLimits::default() - .max_allocations(10_000_000) - .max_memory(128 * 1024 * 1024); + let limits = PythonLimits::default().max_memory(128 * 1024 * 1024); let py = Python::with_limits(limits); let args = vec!["-c".to_string(), "print(sum(range(100)))".to_string()]; let env = opt_in_env(); @@ -1509,11 +1514,9 @@ mod tests { #[test] fn test_python_limits_builder() { let limits = PythonLimits::default() - .max_allocations(500) .max_duration(Duration::from_secs(10)) .max_memory(1024) .max_recursion(50); - assert_eq!(limits.common.max_allocations, 500); assert_eq!(limits.common.max_duration, Duration::from_secs(10)); assert_eq!(limits.common.max_memory, 1024); assert_eq!(limits.common.max_call_depth, 50); @@ -1522,7 +1525,6 @@ mod tests { #[test] fn test_python_limits_default() { let limits = PythonLimits::default(); - assert_eq!(limits.common.max_allocations, 1_000_000); assert_eq!(limits.common.max_duration, Duration::from_secs(30)); assert_eq!(limits.common.max_memory, 64 * 1024 * 1024); assert_eq!(limits.common.max_call_depth, 200); diff --git a/crates/bashkit/src/builtins/runtime_limits.rs b/crates/bashkit/src/builtins/runtime_limits.rs index fae361bae..2bc897c1f 100644 --- a/crates/bashkit/src/builtins/runtime_limits.rs +++ b/crates/bashkit/src/builtins/runtime_limits.rs @@ -1,12 +1,16 @@ //! Shared resource limits for embedded language runtimes. //! //! The embedded VM builtins (Python via Monty, TypeScript via ZapCode) each -//! need the same core sandbox knobs: a wall-clock budget, a memory cap, an -//! allocation cap, and a call-depth cap. Rather than each runtime re-declaring -//! those four axes, their defaults, and their fluent setters, they share this -//! [`RuntimeLimits`] core and add only runtime-specific naming/defaults on top -//! (e.g. Python calls the depth knob `max_recursion`, TypeScript calls it -//! `max_stack_depth`). +//! need the same core sandbox knobs: a wall-clock budget, a memory cap, and a +//! call-depth cap. Rather than each runtime re-declaring those three axes, +//! their defaults, and their fluent setters, they share this [`RuntimeLimits`] +//! core and add only runtime-specific naming/defaults on top (e.g. Python calls +//! the depth knob `max_recursion`, TypeScript calls it `max_stack_depth`). +//! +//! Important decision: the allocation-count cap is *not* shared. Monty dropped +//! `max_allocations` from its resource limits in 0.0.19, so only ZapCode still +//! enforces one; it lives on `TypeScriptLimits` directly. Keeping it here would +//! hand Python callers a security knob that silently does nothing. //! //! This is intentionally scoped to *general-purpose VM* runtimes. SQLite is a //! query engine, not a VM — its limits (rows, statements, PRAGMAs, db size) @@ -19,8 +23,6 @@ use std::time::Duration; pub(crate) const DEFAULT_MAX_DURATION: Duration = Duration::from_secs(30); /// Default memory cap (64 MB). pub(crate) const DEFAULT_MAX_MEMORY: usize = 64 * 1024 * 1024; -/// Default heap-allocation cap. -pub(crate) const DEFAULT_MAX_ALLOCATIONS: usize = 1_000_000; /// Neutral default call-depth cap; each runtime overrides with its own default. pub(crate) const DEFAULT_MAX_CALL_DEPTH: usize = 256; @@ -35,8 +37,6 @@ pub struct RuntimeLimits { pub max_duration: Duration, /// Maximum memory in bytes (default: 64 MB). pub max_memory: usize, - /// Maximum heap allocations (default: 1,000,000). - pub max_allocations: usize, /// Maximum call/recursion depth. Defaults vary per runtime. pub max_call_depth: usize, } @@ -46,7 +46,6 @@ impl Default for RuntimeLimits { Self { max_duration: DEFAULT_MAX_DURATION, max_memory: DEFAULT_MAX_MEMORY, - max_allocations: DEFAULT_MAX_ALLOCATIONS, max_call_depth: DEFAULT_MAX_CALL_DEPTH, } } @@ -67,13 +66,6 @@ impl RuntimeLimits { self } - /// Set the heap-allocation cap. - #[must_use] - pub fn max_allocations(mut self, n: usize) -> Self { - self.max_allocations = n; - self - } - /// Set the call/recursion-depth cap. #[must_use] pub fn max_call_depth(mut self, depth: usize) -> Self { @@ -91,7 +83,6 @@ mod tests { let limits = RuntimeLimits::default(); assert_eq!(limits.max_duration, DEFAULT_MAX_DURATION); assert_eq!(limits.max_memory, DEFAULT_MAX_MEMORY); - assert_eq!(limits.max_allocations, DEFAULT_MAX_ALLOCATIONS); assert_eq!(limits.max_call_depth, DEFAULT_MAX_CALL_DEPTH); } @@ -100,11 +91,9 @@ mod tests { let limits = RuntimeLimits::default() .max_duration(Duration::from_secs(7)) .max_memory(2048) - .max_allocations(99) .max_call_depth(33); assert_eq!(limits.max_duration, Duration::from_secs(7)); assert_eq!(limits.max_memory, 2048); - assert_eq!(limits.max_allocations, 99); assert_eq!(limits.max_call_depth, 33); } } diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 88c50e0d2..1468ea08f 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -28,10 +28,14 @@ use crate::error::Result; use crate::fs::FileSystem; use crate::interpreter::ExecResult; -/// TypeScript's default call-stack-depth cap. The other VM limits (duration, -/// memory, allocations) default via [`RuntimeLimits::default`]. +/// TypeScript's default call-stack-depth cap. The other shared VM limits +/// (duration, memory) default via [`RuntimeLimits::default`]. const DEFAULT_MAX_STACK_DEPTH: usize = 512; +/// Default heap-allocation cap. TypeScript-only: ZapCode counts allocations, +/// Monty stopped exposing that axis in 0.0.19, so it is not a shared limit. +const DEFAULT_MAX_ALLOCATIONS: usize = 1_000_000; + /// Resource limits for the embedded TypeScript (ZapCode) interpreter. /// /// Use the builder pattern to customize, or `Default` for the standard limits: @@ -58,8 +62,11 @@ const DEFAULT_MAX_STACK_DEPTH: usize = 512; /// setters below configure them directly. #[derive(Debug, Clone)] pub struct TypeScriptLimits { - /// Shared VM resource limits (duration, memory, allocations, call depth). + /// Shared VM resource limits (duration, memory, call depth). pub common: RuntimeLimits, + /// Maximum heap allocations (default: 1,000,000). ZapCode-specific — the + /// Python runtime has no equivalent cap. + pub max_allocations: usize, } impl Default for TypeScriptLimits { @@ -70,6 +77,7 @@ impl Default for TypeScriptLimits { max_call_depth: DEFAULT_MAX_STACK_DEPTH, ..RuntimeLimits::default() }, + max_allocations: DEFAULT_MAX_ALLOCATIONS, } } } @@ -99,7 +107,7 @@ impl TypeScriptLimits { /// Set max heap allocations. #[must_use] pub fn max_allocations(mut self, n: usize) -> Self { - self.common.max_allocations = n; + self.max_allocations = n; self } @@ -109,7 +117,7 @@ impl TypeScriptLimits { memory_limit_bytes: self.common.max_memory, time_limit_ms: self.common.max_duration.as_millis() as u64, max_stack_depth: self.common.max_call_depth, - max_allocations: self.common.max_allocations, + max_allocations: self.max_allocations, } } } @@ -1237,7 +1245,7 @@ mod tests { assert_eq!(limits.common.max_duration, Duration::from_secs(30)); assert_eq!(limits.common.max_memory, 64 * 1024 * 1024); assert_eq!(limits.common.max_call_depth, 512); - assert_eq!(limits.common.max_allocations, 1_000_000); + assert_eq!(limits.max_allocations, 1_000_000); } #[test] @@ -1250,7 +1258,7 @@ mod tests { assert_eq!(limits.common.max_duration, Duration::from_secs(5)); assert_eq!(limits.common.max_memory, 1024); assert_eq!(limits.common.max_call_depth, 100); - assert_eq!(limits.common.max_allocations, 500); + assert_eq!(limits.max_allocations, 500); } #[test] diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index e8e9e6a12..fb8f9553c 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -537,7 +537,7 @@ pub use builtins::RuntimeLimits; #[cfg(feature = "sqlite")] pub use builtins::{Sqlite, SqliteBackend, SqliteLimits}; // Re-export monty types needed by external handler consumers. -// **Unstable:** These types come from monty (git-pinned, not on crates.io). +// **Unstable:** These types come from monty, which is pre-1.0 (`0.0.x`). // They may change in breaking ways between bashkit releases. #[cfg(feature = "python")] pub use monty_types::{ExcType, ExtFunctionResult, MontyException, MontyObject}; @@ -1925,7 +1925,7 @@ impl BashBuilder { /// with default resource limits. /// /// Monty runs directly in the host process with resource limits enforced - /// by Monty's runtime (memory, allocations, time, recursion). + /// by Monty's runtime (memory, time, recursion). /// /// For security, execution is runtime-gated: set /// `BASHKIT_ALLOW_INPROCESS_PYTHON=1` via builder `.env(...)` before diff --git a/crates/bashkit/tests/integration/python_integration_tests.rs b/crates/bashkit/tests/integration/python_integration_tests.rs index ca2b085bb..9d5fdb198 100644 --- a/crates/bashkit/tests/integration/python_integration_tests.rs +++ b/crates/bashkit/tests/integration/python_integration_tests.rs @@ -1209,9 +1209,7 @@ mod resource_limits { #[tokio::test] async fn generous_limits_succeed() { - let limits = PythonLimits::default() - .max_allocations(10_000_000) - .max_memory(128 * 1024 * 1024); + let limits = PythonLimits::default().max_memory(128 * 1024 * 1024); let mut bash = bash_python_limits(limits); let r = bash .exec("python3 -c \"print(sum(range(1000)))\"") diff --git a/crates/bashkit/tests/integration/python_security_tests.rs b/crates/bashkit/tests/integration/python_security_tests.rs index 8773c23c1..d263fd275 100644 --- a/crates/bashkit/tests/integration/python_security_tests.rs +++ b/crates/bashkit/tests/integration/python_security_tests.rs @@ -34,7 +34,6 @@ fn bash_python_tight() -> Bash { PythonLimits::default() .max_duration(Duration::from_secs(5)) .max_memory(4 * 1024 * 1024) // 4 MB - .max_allocations(100_000) .max_recursion(50), ) } @@ -298,18 +297,57 @@ mod whitebox_resource_limits { } #[tokio::test] - async fn tight_allocation_blocks_many_objects() { - let limits = PythonLimits::default().max_allocations(10); + async fn tight_memory_blocks_many_small_objects() { + // Monty dropped its allocation-count cap in 0.0.19, so an + // allocation bomb of many small objects is contained by the memory + // budget instead. + let limits = PythonLimits::default().max_memory(512 * 1024); let mut bash = bash_python_limits(limits); let r = bash .exec("python3 -c \"x = [i for i in range(100000)]\"") .await .unwrap(); - // Very tight allocation limit should eventually block; if not, - // at minimum verify no crash/panic occurred + assert_ne!( + r.exit_code, 0, + "100k-element list should exceed a 512 KB memory budget" + ); assert!(!r.stderr.contains("panic"), "Should not panic"); } + #[tokio::test] + async fn print_output_capped_by_memory_limit() { + // Collected print output lives in a host-side buffer, not on the VM + // heap, so Monty 0.0.19 caps it separately. Bashkit wires that cap to + // max_memory: a print loop cannot outgrow the declared budget even + // though the script itself allocates almost nothing. + let limits = PythonLimits::default() + .max_memory(256 * 1024) + .max_duration(Duration::from_secs(30)); + let mut bash = bash_python_limits(limits); + let r = bash + .exec("python3 -c \"for i in range(10000000):\n print('x' * 100)\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "Print flood should hit the output cap"); + assert!( + r.stdout.len() <= 256 * 1024, + "stdout should stay within the 256 KB budget, got {} bytes", + r.stdout.len() + ); + } + + #[tokio::test] + async fn print_output_under_cap_succeeds() { + let limits = PythonLimits::default().max_memory(256 * 1024); + let mut bash = bash_python_limits(limits); + let r = bash + .exec("python3 -c \"for i in range(100):\n print('x' * 100)\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0, "stderr={:?}", r.stderr); + assert_eq!(r.stdout.len(), 100 * 101); + } + #[tokio::test] async fn tight_duration_blocks_slow_code() { let limits = PythonLimits::default().max_duration(Duration::from_millis(100)); @@ -379,7 +417,6 @@ mod whitebox_resource_limits { async fn dict_comprehension_bomb() { let limits = PythonLimits::default() .max_memory(2 * 1024 * 1024) // 2 MB - .max_allocations(50_000) .max_duration(Duration::from_secs(3)); let mut bash = bash_python_limits(limits); let r = bash @@ -395,7 +432,6 @@ mod whitebox_resource_limits { // genuinely new large lists at each level to force allocations. let limits = PythonLimits::default() .max_memory(2 * 1024 * 1024) - .max_allocations(50_000) .max_duration(Duration::from_secs(3)); let mut bash = bash_python_limits(limits); let r = bash @@ -408,8 +444,8 @@ mod whitebox_resource_limits { #[tokio::test] async fn generator_exhaustion() { let limits = PythonLimits::default() - .max_duration(Duration::from_secs(2)) - .max_allocations(50_000); + .max_memory(2 * 1024 * 1024) + .max_duration(Duration::from_secs(2)); let mut bash = bash_python_limits(limits); let r = bash .exec("python3 -c \"list(range(10000000))\"") @@ -420,15 +456,18 @@ mod whitebox_resource_limits { #[tokio::test] async fn successive_allocations_accumulate() { - // Verify allocations aren't reset between statements - let limits = PythonLimits::default().max_allocations(500); + // Verify the memory budget isn't reset between statements: one 10k + // list fits in 256 KB, three do not. + let limits = PythonLimits::default().max_memory(256 * 1024); let mut bash = bash_python_limits(limits); let r = bash .exec("python3 -c \"a = list(range(10000))\nb = list(range(10000))\nc = list(range(10000))\"") .await .unwrap(); - // With only 500 allocations allowed and 30k objects requested, - // should fail. If Monty counts allocations differently, at least no crash. + assert_ne!( + r.exit_code, 0, + "Three 10k lists should exhaust a 256 KB budget that one does not" + ); assert!(!r.stderr.contains("panic"), "Should not panic"); } } @@ -796,15 +835,15 @@ mod whitebox_state_isolation { #[tokio::test] async fn resource_limits_enforced_each_execution() { - let limits = PythonLimits::default().max_allocations(50_000); + let limits = PythonLimits::default().max_memory(4 * 1024 * 1024); let mut bash = bash_python_limits(limits); - // First execution uses some allocations + // First execution uses some of the budget let r1 = bash .exec("python3 -c \"x = list(range(100))\nprint('ok')\"") .await .unwrap(); assert_eq!(r1.exit_code, 0); - // Second execution should have fresh allocation budget + // Second execution should have a fresh memory budget let r2 = bash .exec("python3 -c \"x = list(range(100))\nprint('ok')\"") .await @@ -896,15 +935,14 @@ mod blackbox_dos { .exec("python3 -c \"for i in range(10000000):\n print(i)\"") .await .unwrap(); - // Should hit allocation or time limits, not produce gigabytes of output + // Should hit the print-collect cap or the time limit, not produce + // gigabytes of output assert_ne!(r.exit_code, 0, "Print flood should be stopped by limits"); } #[tokio::test] async fn exception_chain_bomb() { - let limits = PythonLimits::default() - .max_duration(Duration::from_secs(5)) - .max_allocations(100_000); + let limits = PythonLimits::default().max_duration(Duration::from_secs(5)); let mut bash = bash_python_limits(limits); let r = bash .exec("python3 -c \"def bomb(n):\n try:\n bomb(n+1)\n except RecursionError:\n raise ValueError('boom') from None\nbomb(0)\"") diff --git a/deny.toml b/deny.toml index 269ce5cb5..726acaf72 100644 --- a/deny.toml +++ b/deny.toml @@ -51,12 +51,10 @@ deny = [] unknown-registry = "deny" unknown-git = "deny" -# Allow git sources for Monty (not yet on crates.io) and its ruff deps. -# jiter is pinned to upstream main via [patch.crates-io] in the root -# Cargo.toml so the workspace can run pyo3 0.29 (security fix); see the -# patch comment there. +# Monty and its ruff deps now come from crates.io (since monty 0.0.19), so the +# only remaining git source is jiter: pinned to upstream main via +# [patch.crates-io] in the root Cargo.toml so the workspace can run pyo3 0.29 +# (security fix); see the patch comment there. allow-git = [ - "https://github.com/pydantic/monty", - "https://github.com/samuelcolvin/ruff", "https://github.com/pydantic/jiter", ] diff --git a/docs/cli.md b/docs/cli.md index 727c72c8e..c7d9eefa7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -177,8 +177,9 @@ bashkit -c 'python3 -c "print(2 + 2)"' # 4 ``` -`cargo install bashkit-cli` from crates.io 0.1.21 does not include the CLI -`python` feature. Until the next release, install from main: +Monty is on crates.io since 0.0.19, so `python` is a default CLI feature and +`cargo install bashkit-cli` ships it. Releases published before that stripped +the feature at publish time; if you are on one of those, install from main: ```bash cargo install --git https://github.com/everruns/bashkit --package bashkit-cli --features python --force diff --git a/docs/start-rust.md b/docs/start-rust.md index 09ede8aec..c5f940716 100644 --- a/docs/start-rust.md +++ b/docs/start-rust.md @@ -15,17 +15,17 @@ Opt-in features pull in heavier capabilities only when you need them: ```bash cargo add bashkit --features http_client cargo add bashkit --features git +cargo add bashkit --features python cargo add bashkit --features typescript cargo add bashkit --features sqlite cargo add bashkit --features realfs cargo add bashkit --features scripted_tool ``` -`http_client` enables `curl`/`wget` and the network allowlist. Embedded Python -(Monty) is a git-only dependency, so there is no `python` feature from the -crates.io release — to run Python inside the shell see the [Python -builtin](python.md) guide, and to embed Bashkit *in* a Python app see [Get -started in Python](start-python.md). +`http_client` enables `curl`/`wget` and the network allowlist. `python` +embeds the [Monty](https://github.com/pydantic/monty) interpreter — see the +[Python builtin](python.md) guide to run Python inside the shell, and [Get +started in Python](start-python.md) to embed Bashkit *in* a Python app. ## First script diff --git a/specs/python-builtin.md b/specs/python-builtin.md index 9168b15cb..f57e0d513 100644 --- a/specs/python-builtin.md +++ b/specs/python-builtin.md @@ -33,7 +33,7 @@ For security, execution is runtime-gated on `BASHKIT_ALLOW_INPROCESS_PYTHON=1` - Pure Rust, no CPython dependency - Sub-microsecond startup -- Built-in resource limits (memory, allocations, time, recursion depth) +- Built-in resource limits (memory, time, recursion depth) - No filesystem/network access by design (sandbox-safe) - Snapshotable execution state @@ -50,11 +50,15 @@ configurable via `PythonLimits`: | Limit | Default | Builder Method | |-------|---------|----------------| -| Max allocations | 1,000,000 | `.max_allocations(n)` | | Max duration | 30 s | `.max_duration(d)` | | Max memory | 64 MB | `.max_memory(bytes)` | | Max recursion | 200 | `.max_recursion(depth)` | +`max_memory` does double duty: Monty caps the host-side buffer that collects +`print` output at the same value, so a print loop cannot outgrow the declared +budget even though it allocates nothing on the VM heap. There is no +allocation-count knob — Monty removed `max_allocations` in 0.0.19. + Since Monty 0.0.4 the parser also enforces a nesting-depth limit (200 release / 35 debug) against stack overflow from deeply nested expressions. @@ -117,7 +121,7 @@ let bash = Bash::builder() - **Dispatch:** one handler receives all registered names; dispatch on `function_name` inside it. - **Timeouts:** Each awaited handler call is wrapped in the remaining `PythonLimits::max_duration` wall-clock budget for the current Python invocation. If the budget expires while a handler is pending, Bashkit resumes Python with a `RuntimeError` instead of waiting for the handler indefinitely. - **Trust model:** same as `BashBuilder::builtin()` and `ScriptedTool` callbacks — host registers trusted Rust code, untrusted scripts invoke by name. Handlers are trusted host code and should still enforce independent limits for outbound I/O, remote services, and other resources they consume. -- **Unstable re-exports:** `MontyObject`, `ExtFunctionResult`, `MontyException`, `ExcType` re-exported from the `monty` crate (git-pinned, not on crates.io); may break between bashkit releases. +- **Unstable re-exports:** `MontyObject`, `ExtFunctionResult`, `MontyException`, `ExcType` re-exported from the `monty` crate (pre-1.0, tracked at `0.0.x`); may break between bashkit releases. ### Security @@ -125,7 +129,7 @@ See `specs/threat-model.md` § "Python / Monty Security (TM-PY)" for the full analysis. Summary: - **Code injection via bash expansion**: variables expand before reaching the builtin (by-design, consistent with all builtins); use single quotes to prevent. -- **Resource exhaustion**: Monty's allocation/time/memory caps apply even when shell limits are generous; print output is captured in memory and bounded by the memory cap. +- **Resource exhaustion**: Monty's time/memory caps apply even when shell limits are generous; print output is captured in memory and bounded by the same memory cap. - **Sandbox escape via filesystem**: all path ops go through the VFS; `/etc/passwd` reads VFS, not host. Relative paths resolve against the shell cwd; `../..` traversal constrained by VFS path normalization. - **Sandbox escape via os/subprocess/socket**: not implemented in Monty; raise errors. diff --git a/specs/release-process.md b/specs/release-process.md index d30d13fcb..2c6421d3a 100644 --- a/specs/release-process.md +++ b/specs/release-process.md @@ -50,9 +50,8 @@ silently failed. --all-targets --all-features -- -D warnings`, `cargo test`. 5. **Verify publish-readiness** (catches what local tests don't — the `cargo publish` packaging step, missing files, version drift): - - `cargo publish --dry-run -p bashkit` must succeed in a disposable copy - after applying the same git-only Monty/Python manifest transform as - `publish.yml`. Package `bashkit-cli` there against the latest published + - `cargo publish --dry-run -p bashkit` must succeed. Package + `bashkit-cli` in a disposable copy against the latest published core version as a structural proxy; Cargo cannot resolve the CLI's new registry dependency until the core crate is live. Normal workspace checks still compile the CLI against the new local core, and `publish.yml` waits diff --git a/specs/threat-model.md b/specs/threat-model.md index ceb2c5487..613078101 100644 --- a/specs/threat-model.md +++ b/specs/threat-model.md @@ -1437,8 +1437,8 @@ yield `OsCall` events that Bashkit intercepts and dispatches to the VFS. | ID | Threat | Severity | Mitigation | Test | |----|--------|----------|------------|------| -| TM-PY-001 | Infinite loop via `while True` | High | Monty time limit (30s) + allocation cap | `threat_python_infinite_loop` | -| TM-PY-002 | Memory exhaustion via large allocation | High | Monty max_memory (64MB) + max_allocations (1M) | `threat_python_memory_exhaustion` | +| TM-PY-001 | Infinite loop via `while True` | High | Monty time limit (30s) | `threat_python_infinite_loop` | +| TM-PY-002 | Memory exhaustion via large allocation | High | Monty max_memory (64MB), which also caps collected `print` output | `threat_python_memory_exhaustion` | | TM-PY-003 | Stack overflow via deep recursion | High | Monty max_recursion (200) + parser depth limit (200, since 0.0.4) | `threat_python_recursion_bomb` | | TM-PY-004 | Shell escape via os.system/subprocess | Critical | Monty has no os.system/subprocess implementation | `threat_python_no_os_operations` | | TM-PY-005 | Real filesystem access via open() | Critical | VFS bridge opens only Bashkit VFS files, not host files | `threat_python_no_filesystem` | From 9054a5c960305e3a5b143e777aa1e2646adba541 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 25 Jul 2026 04:43:46 +0000 Subject: [PATCH 2/2] chore(deps): vet monty as a crates.io dependency `[policy.monty] audit-as-crates-io = false` only applied while monty was a git dependency. Now that it resolves from the registry, cargo-vet requires the crates themselves: drop the stale policy and exempt monty, monty-macros, and monty-types at 0.0.19, matching how monty's own ruff_* deps are already handled. `cargo vet --locked` passes. Claude-Session: https://claude.ai/code/session_01CA9FHReY9cMTzeTLrkeU4Q --- supply-chain/config.toml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index c268a9ada..026159e18 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -27,10 +27,6 @@ criteria = "safe-to-deploy" audit-as-crates-io = false criteria = "safe-to-deploy" -[policy.monty] -audit-as-crates-io = false -criteria = "safe-to-deploy" - [[exemptions.adler2]] version = "2.0.1" criteria = "safe-to-deploy" @@ -1119,6 +1115,18 @@ criteria = "safe-to-deploy" version = "0.2.3" criteria = "safe-to-deploy" +[[exemptions.monty]] +version = "0.0.19" +criteria = "safe-to-deploy" + +[[exemptions.monty-macros]] +version = "0.0.19" +criteria = "safe-to-deploy" + +[[exemptions.monty-types]] +version = "0.0.19" +criteria = "safe-to-deploy" + [[exemptions.napi]] version = "3.10.5" criteria = "safe-to-deploy"