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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ updates:
# see knowledge/runtimes/python-builtin.md § Upgrade blocker.
- dependency-name: "monty"
- dependency-name: "monty-types"
# The python `re` gate parses user code with the same ruff parser Monty
# 0.0.19 bundles. A standalone bump would let the gate's grammar drift
# from the executor's, and code the gate fails to parse is passed
# through. Move these only together with monty.
- dependency-name: "ruff_python_parser"
- dependency-name: "ruff_python_ast"

# wasmtime is pinned exactly (=48.0.6): the `cpython` feature embeds a
# module precompiled by build.rs, and a precompiled module loads only
Expand Down
28 changes: 22 additions & 6 deletions crates/bashkit/src/builtins/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,9 @@ impl Default for Python {
// statement that puts the import after a prefix. Monty 0.0.19 has no import
// hook, so bashkit parses with the same parser Monty runs first
// (`ruff_python_parser::parse_module`) and walks every statement body.
// Monty has no `__import__` or `importlib`, so only `import` / `from` statements
// can load a module. Code that fails to parse is let through: Monty parses it
// Monty has no `__import__`, `importlib`, `exec`, `eval` or `compile`, so only
// `import` / `from` statements can load a module; the tripwire for that is
// `test_re_module_dynamic_import_is_unavailable`. Code that fails to parse is let through: Monty parses it
// the same way, reports the SyntaxError and runs nothing.
fn is_disabled_import(code: &str) -> Option<&'static str> {
use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt};
Expand Down Expand Up @@ -553,6 +554,10 @@ impl Builtin for Python {
));
};

// Charge the source before the import gate parses it, so untrusted
// input is accounted for before any work is done on it.
ctx.consume_budget_input(code.len())?;
ctx.consume_budget_work(u64::try_from(code.len().div_ceil(64)).unwrap_or(u64::MAX))?;
if let Some(module) = is_disabled_import(&code) {
return Ok(ExecResult::err(
format!(
Expand All @@ -561,8 +566,6 @@ impl Builtin for Python {
1,
));
}
ctx.consume_budget_input(code.len())?;
ctx.consume_budget_work(u64::try_from(code.len().div_ceil(64)).unwrap_or(u64::MAX))?;

// THREAT[TM-INF]: Python environment access is intentionally scoped
// to exported variables only (`ctx.env`). Shell-local variables in
Expand Down Expand Up @@ -2017,8 +2020,9 @@ mod tests {
async fn test_re_module_dynamic_import_is_unavailable() {
// Monty has no `__import__` or `importlib`, so import statements are
// the only way to load a module. Pin that: if Monty adds either, the
// AST gate in `is_disabled_import` must learn about it.
let r = run(&["-c", "m = __import__('re')\nprint(m)"], None).await;
// AST gate in `is_disabled_import` must learn about it. Payloads use
// only supported modules, so the call itself is what fails.
let r = run(&["-c", "m = __import__('json')\nprint(m)"], None).await;
assert_eq!(r.exit_code, 1, "{}", r.stdout);
assert!(r.stdout.is_empty(), "{}", r.stdout);
let r = run(&["-c", "import importlib"], None).await;
Expand All @@ -2028,6 +2032,18 @@ mod tests {
"{}",
r.stderr
);
// `exec`/`eval`/`compile` would run an import the outer AST never
// shows. Monty has none of them; pin that too. Each payload succeeds
// as soon as the builtin exists, so adding one turns this test red.
for code in [
"exec('x = 41')\nprint(x)",
"print(eval('6 * 7'))",
"c = compile('x = 1', 'x', 'exec')\nprint(c)",
] {
let r = run(&["-c", code], None).await;
assert_eq!(r.exit_code, 1, "{code}: {}", r.stdout);
assert!(r.stdout.is_empty(), "{code}: {}", r.stdout);
}
}

#[tokio::test]
Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* **Python `re` gate parses code**: the embedded Monty `python3` blocked `import re` by matching lines that start with `import`/`from`, so `pass; import re` or `if True: import re` loaded the module anyway (found by a downstream integration review). The gate now walks the parsed statement tree. See [Python Builtin](runtimes/python-builtin.md).
* **Python call cap and final-value hang**: each embedded `python3` call reserved `max_memory / 64` work units up front, so a default script failed on its 96th call; heap growth is now charged as it happens. The last expression is no longer echoed (CPython never does for `-c`/files/stdin): exporting it ran outside Monty's limits, and `x = [x, x]` thirty times hung past the timeout. Found reviewing a downstream integration. See [Python Builtin](runtimes/python-builtin.md).
* **`re` gate follow-ups**: source is charged to the execution budget before the gate parses it; `ruff_python_parser`/`ruff_python_ast` are Dependabot-ignored so they move only with Monty; a test pins that Monty has no `exec`/`eval`/`compile`. See [Python Builtin](runtimes/python-builtin.md).
* **Python hint and timeouts**: the Monty `python3` LLM hint said "No classes" after Monty 0.0.19 added simple classes, and never mentioned the `re` block; it now matches the runtime and `test_llm_hint_matches_runtime` imports every module it names. A Monty time-limit hit now exits 124 with `python3: execution timed out after Ns`, like the CPython runtime, instead of exit 1 and a `TimeoutError` traceback. See [Python Builtin](runtimes/python-builtin.md).

## 2026-11-03
Expand Down
7 changes: 5 additions & 2 deletions knowledge/runtimes/python-builtin.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,11 @@ the code with `ruff_python_parser` (the parser Monty itself runs first) and walk
every statement body, so `pass; import re`, `if x: import re` or an import inside
a function, class, loop or `try` is caught. Strings and comments that mention
`re` are not. A line-prefix scan used before 2026-11-05 missed all of these.
Monty 0.0.19 has no `__import__` or `importlib`, so `import`/`from` statements
are the only module loaders; a unit test pins that.
Monty 0.0.19 has no `__import__`, `importlib`, `exec`, `eval` or `compile`, so
`import`/`from` statements are the only module loaders; a unit test pins that.
`ruff_python_parser`/`ruff_python_ast` are pinned to Monty's bundled 0.0.3 and
ignored by Dependabot, so the gate and the executor parse the same grammar.
Source is charged to the execution budget before the gate parses it.

### Integration with Bashkit

Expand Down
Loading