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
2 changes: 2 additions & 0 deletions Cargo.lock

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

13 changes: 12 additions & 1 deletion crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ monty-types = { version = "=0.0.19", optional = true }
# breaks the `GetSize` derive (#2381). Dependabot ignores it for that reason;
# keep the ignore in .github/dependabot.yml in step with this pin.
get-size2 = { version = "=0.10.1", optional = true }
# Same parser Monty uses (already in the tree through monty =0.0.19). Bashkit
# parses user code with it to find `import` statements; see
# `is_disabled_import` in builtins/python.rs. Keep in step with Monty's pin.
ruff_python_parser = { version = "=0.0.3", optional = true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Nothing enforces the "keep in step with Monty's pin" invariant: .github/dependabot.yml ignores monty/monty-types (held at 0.0.19) but not these two crates, so a Dependabot bump of ruff_python_parser/ast past Monty's bundled 0.0.3 silently skews the gate's grammar from the executor's. Skew in the wrong direction (gate rejects what Monty accepts) turns into pass-through (parse_module(...).ok()? returns None) and reopens the hole. Please add the same ignore entries for ruff_python_parser/ruff_python_ast.

ruff_python_ast = { version = "=0.0.3", optional = true }

# Embedded TypeScript interpreter (optional)

Expand Down Expand Up @@ -261,7 +266,13 @@ git = []
ssh = ["russh"]
# Enable python/python3 builtins via embedded Monty interpreter
# Usage: cargo build --features python
python = ["dep:monty", "dep:monty-types", "dep:get-size2"]
python = [
"dep:monty",
"dep:monty-types",
"dep:get-size2",
"dep:ruff_python_parser",
"dep:ruff_python_ast",
]
# Enable python/python3 builtins via real CPython 3.14 in a WebAssembly sandbox
# Usage: cargo build --features cpython
cpython = ["dep:bashkit-cpython-wasm", "dep:wasmtime"]
Expand Down
148 changes: 93 additions & 55 deletions crates/bashkit/src/builtins/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,66 +346,53 @@ impl Default for Python {
}
}

// Important security decision: find imports by parsing, not by scanning lines.
// A line-prefix scan missed `pass; import re`, `if x: import re` and any other
// 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
// the same way, reports the SyntaxError and runs nothing.
fn is_disabled_import(code: &str) -> Option<&'static str> {
let is_disabled_module = |module: &str| {
DISABLED_STDLIB_MODULES.iter().any(|disabled| {
module == *disabled
|| (module.starts_with(disabled)
&& module
.as_bytes()
.get(disabled.len())
.is_some_and(|next| *next == b'.'))
use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt};
use ruff_python_ast::{Stmt, StmtImport, StmtImportFrom};

fn disabled(module: &str) -> Option<&'static str> {
DISABLED_STDLIB_MODULES.iter().copied().find(|disabled| {
module
.strip_prefix(disabled)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
})
};
}

for raw_line in code.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
struct ImportFinder(Option<&'static str>);

if let Some(imports) = line.strip_prefix("import ") {
for import in imports.split(',') {
let module = import
.split_whitespace()
.next()
.unwrap_or("")
.trim()
.trim_matches(|c: char| c == '\'' || c == '"')
.trim_end_matches(|c: char| {
!(c.is_ascii_alphanumeric() || c == '_' || c == '.')
})
.to_string();
if is_disabled_module(&module) {
return Some("re");
}
impl<'a> StatementVisitor<'a> for ImportFinder {
fn visit_stmt(&mut self, stmt: &'a Stmt) {
if self.0.is_some() {
return;
}
}

if let Some(from_stmt) = line.strip_prefix("from ") {
let module = from_stmt
.split_whitespace()
.next()
.unwrap_or("")
.trim()
.trim_matches(|c: char| c == '\'' || c == '"')
.trim_end_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
.to_string();
if is_disabled_module(&module) {
return Some("re");
match stmt {
Stmt::Import(StmtImport { names, .. }) => {
self.0 = names.iter().find_map(|alias| disabled(alias.name.as_str()));
}
Stmt::ImportFrom(StmtImportFrom { module, names, .. }) => {
self.0 = match module {
Some(module) => disabled(module.as_str()),
// `from . import re`
None => names.iter().find_map(|alias| disabled(alias.name.as_str())),
};
}
_ => walk_stmt(self, stmt),
}
}

if line.contains("__import__('re'") || line.contains("__import__(\"re\"") {
return Some("re");
}
if line.contains("importlib.import_module('re'")
|| line.contains("importlib.import_module(\"re\"")
{
return Some("re");
}
}
None

let parsed = ruff_python_parser::parse_module(code).ok()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Untrusted input is fully AST-parsed before any budget is charged: the caller runs this gate and only afterwards calls consume_budget_input/work (execute(), ~lines 517-526). A multi-MB -c string or script file gets a full parse_module before accounting. Consider consuming input budget (or enforcing a length cap) before parsing.

let mut finder = ImportFinder(None);
finder.visit_body(&parsed.syntax().body);
finder.0
}

#[async_trait]
Expand Down Expand Up @@ -1913,10 +1900,61 @@ mod tests {
}

#[tokio::test]
async fn test_re_module_dynamic_import_is_blocked() {
let r = run(&["-c", "m = __import__('re')"], None).await;
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The exec/eval/compile dynamic path is not pinned. The gate is purely static (single check in execute(); the old bypass "loaded the module anyway" per knowledge/log.md, so there is no runtime enforcement), and the outer AST of exec('import re') contains no Stmt::Import node, so it sails through. This test pins __import__ and importlib but not exec("import re") / compile+exec, and the existing blackbox no_exec/no_compile tests assert only !stdout.contains("hacked"), which passes whether or not the call executes. Please add a regression test such as run(&["-c", "exec('import re')\nprint(re.search('a','a'))"]]) asserting re stays unusable (or assert explicitly that exec is unavailable), so a Monty upgrade that adds/changes exec fails loudly instead of silently reopening the gate.

assert_eq!(r.exit_code, 1, "{}", r.stdout);
assert!(r.stdout.is_empty(), "{}", r.stdout);
let r = run(&["-c", "import importlib"], None).await;
assert_eq!(r.exit_code, 1);
assert!(r.stderr.contains("importing module 're' is disabled"));
assert!(
r.stderr.contains("No module named 'importlib'"),
"{}",
r.stderr
);
}

#[tokio::test]
async fn test_re_module_hidden_imports_are_blocked() {
// Regression: the gate used to match only lines starting with
// `import`/`from`, so any statement prefix bypassed it.
for code in [
"pass; import re",
"x = 1; from re import split",
"if True: import re",
"if True:\n import re",
"def f():\n import re\n return re\nf()",
"try:\n import re\nexcept Exception:\n pass",
"for _ in [1]: import json, re",
"with open('/tmp/x', 'w') as f: import re.sub",
"import json as j, re as r",
"class C:\n import re",
] {
let r = run(&["-c", code], None).await;
assert_eq!(r.exit_code, 1, "{code}: {}", r.stderr);
assert!(
r.stderr.contains("importing module 're' is disabled"),
"{code}: {}",
r.stderr
);
}
}

#[tokio::test]
async fn test_re_named_text_is_not_an_import() {
// Strings, comments and other modules that merely mention `re` run.
let r = run(
&[
"-c",
"import json\nre = 'import re'\n# import re\nprint(re, json.dumps('re'))",
],
None,
)
.await;
assert_eq!(r.exit_code, 0, "{}", r.stderr);
assert_eq!(r.stdout, "import re \"re\"\n");
}

#[tokio::test]
Expand Down
18 changes: 18 additions & 0 deletions crates/bashkit/tests/integration/python_security_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,24 @@ mod blackbox_dos {
// (this test itself has tokio timeout protection)
}

#[tokio::test]
async fn regex_module_cannot_be_imported_behind_a_statement_prefix() {
let mut bash = bash_python_limits(PythonLimits::default());
for code in [
"pass; import re",
"if True: import re",
"def f():\n import re\nf()",
] {
let r = bash
.exec(&format!("python3 - <<'EOF'\n{code}\nprint('loaded')\nEOF"))
.await
.unwrap();
assert_eq!(r.exit_code, 1, "{code}: {}", r.stdout);
assert!(r.stdout.is_empty(), "{code}: {}", r.stdout);
assert!(r.stderr.contains("importing module 're' is disabled"));
}
}

#[tokio::test]
async fn math_factorial_bomb() {
let mut bash = bash_python_tight();
Expand Down
4 changes: 4 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Bashkit Knowledge Update Log

## 2026-11-05

* **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).

## 2026-11-03

* **Walk order**: the default `walk` visits directories in path order. It used `read_dir` order (hash order for `InMemoryFs`), so a batch cut short by `max_entries` prefetched listings `tree` never printed and `tree_prefetch_stays_within_entry_budget` read 8-15 directories run to run. See [Virtual Filesystem](foundations/vfs.md#remote-backends-round-trips).
Expand Down
8 changes: 7 additions & 1 deletion knowledge/runtimes/python-builtin.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,13 @@ I/O, no network/classes/third-party imports) through the general
`Builtin::llm_hint()` mechanism (hints deduplicated automatically).

The regex module `re` is intentionally disabled in Bashkit due to
catastrophic-backtracking DoS risk in untrusted code execution.
catastrophic-backtracking DoS risk in untrusted code execution. The gate parses
the code with `ruff_python_parser` (the parser Monty itself runs first) and walks
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.

### Integration with Bashkit

Expand Down
Loading