From d8f576330caee3d12e2e19a94193f1542aeb267e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 5 Nov 2026 04:01:29 +0000 Subject: [PATCH 1/2] fix(python): re gate follow-ups: budget before parse, parser pinned with Monty Review follow-ups for the AST-based `re` import gate: - charge source to the execution budget before the gate parses it - Dependabot-ignore ruff_python_parser/ast so they only move with monty - pin that Monty has no exec/eval/compile, which could hide an import --- .github/dependabot.yml | 6 ++++++ crates/bashkit/src/builtins/python.rs | 17 +++++++++++++++-- knowledge/log.md | 1 + knowledge/runtimes/python-builtin.md | 7 +++++-- 4 files changed, 27 insertions(+), 4 deletions(-) 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 0ea5c9f68..03c0f2a98 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -544,6 +544,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!( @@ -552,8 +556,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 @@ -1969,6 +1971,17 @@ mod tests { "{}", r.stderr ); + // `exec`/`eval`/`compile` would run an import the outer AST never + // shows. Monty has none of them; pin that too. + for code in [ + "exec('import re')\nprint(re)", + "eval(\"__import__('re')\")\nprint(1)", + "c = compile('import re', '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 995eb5245..782d973cb 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). ## 2026-11-03 diff --git a/knowledge/runtimes/python-builtin.md b/knowledge/runtimes/python-builtin.md index 4c2d22cd4..878916c58 100644 --- a/knowledge/runtimes/python-builtin.md +++ b/knowledge/runtimes/python-builtin.md @@ -243,8 +243,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 From 68a7582dfab5a65ca161b1dbc9deb3ecbbb5506d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 5 Nov 2026 04:44:08 +0000 Subject: [PATCH 2/2] test(python): make exec/eval/__import__ pins fail when the builtin appears Payloads used re/__import__, which are also absent, so adding exec or eval alone left the test green. Use supported code instead, and name the full set in the gate's design comment. --- crates/bashkit/src/builtins/python.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index 03c0f2a98..f174c5037 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -381,8 +381,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}; @@ -1960,8 +1961,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; @@ -1972,11 +1974,12 @@ mod tests { r.stderr ); // `exec`/`eval`/`compile` would run an import the outer AST never - // shows. Monty has none of them; pin that too. + // 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('import re')\nprint(re)", - "eval(\"__import__('re')\")\nprint(1)", - "c = compile('import re', 'x', 'exec')\nprint(c)", + "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);