diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b210430e8..e81ed07df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index dca56fdc2..8c4e5293a 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -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}; @@ -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!( @@ -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 @@ -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; @@ -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] diff --git a/knowledge/log.md b/knowledge/log.md index 34517d899..72a9312ba 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -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 diff --git a/knowledge/runtimes/python-builtin.md b/knowledge/runtimes/python-builtin.md index b1a6e9a2d..ad061fdb7 100644 --- a/knowledge/runtimes/python-builtin.md +++ b/knowledge/runtimes/python-builtin.md @@ -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