Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 2 additions & 40 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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 }}

Expand Down
15 changes: 9 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 7 additions & 4 deletions crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions crates/bashkit/docs/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down
8 changes: 4 additions & 4 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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-*)
Expand Down
50 changes: 26 additions & 24 deletions crates/bashkit/src/builtins/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading