diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3036bb10..959e1651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,8 +113,7 @@ jobs: cargo run --example resource_limits cargo run --example text_processing cargo run --example git_workflow --features git - # python_scripts requires monty git dep (not on crates.io) - # cargo run --example python_scripts --features python + cargo run --example python_external_functions --features python # External API dependency — don't block CI on Anthropic outages - name: Run LLM agent example diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 88ce5e97..ff73574b 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -116,3 +116,7 @@ required-features = ["scripted_tool"] [[example]] name = "python_scripts" required-features = ["python"] + +[[example]] +name = "python_external_functions" +required-features = ["python"] diff --git a/crates/bashkit/examples/python_external_functions.rs b/crates/bashkit/examples/python_external_functions.rs new file mode 100644 index 00000000..63839655 --- /dev/null +++ b/crates/bashkit/examples/python_external_functions.rs @@ -0,0 +1,41 @@ +//! Python External Functions Example +//! +//! Demonstrates registering a host async callback that Python can call. +//! +//! Run with: cargo run --features python --example python_external_functions + +use bashkit::{Bash, ExternalResult, MontyObject, PythonExternalFnHandler, PythonLimits, Result}; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<()> { + let handler: PythonExternalFnHandler = Arc::new(|name, args, _kwargs| { + Box::pin(async move { + if name != "add" { + return ExternalResult::Return(MontyObject::None); + } + + let a = match args.first() { + Some(MontyObject::Int(v)) => *v, + _ => 0, + }; + let b = match args.get(1) { + Some(MontyObject::Int(v)) => *v, + _ => 0, + }; + + ExternalResult::Return(MontyObject::Int(a + b)) + }) + }); + + let mut bash = Bash::builder() + .python_with_external_handler(PythonLimits::default(), vec!["add".to_string()], handler) + .build(); + + let result = bash.exec("python3 -c \"print(add(20, 22))\"").await?; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "42"); + + println!("external function result: {}", result.stdout.trim()); + Ok(()) +} diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 8585fa88..e9056328 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -129,7 +129,7 @@ pub use yes::Yes; pub use git::Git; #[cfg(feature = "python")] -pub use python::{Python, PythonLimits}; +pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits}; use async_trait::async_trait; use std::collections::HashMap; diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index f7166cd2..dd93f76b 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -20,7 +20,9 @@ use monty::{ MontyObject, MontyRun, OsFunction, PrintWriter, ResourceLimits, RunProgress, }; use std::collections::HashMap; +use std::future::Future; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -107,6 +109,41 @@ impl PythonLimits { } } +/// Async handler for external Python function calls. +/// +/// Receives `(function_name, positional_args, keyword_args)` directly from monty. +/// Return `ExternalResult::Return(value)` for success or `ExternalResult::Error(exc)` for failure. +pub type PythonExternalFnHandler = Arc< + dyn Fn( + String, + Vec, + Vec<(MontyObject, MontyObject)>, + ) -> Pin + Send>> + + Send + + Sync, +>; + +/// External function configuration for the Python builtin. +/// +/// Groups function names and their async handler together. +/// Configure via [`BashBuilder::python_with_external_handler`](crate::BashBuilder::python_with_external_handler). +#[derive(Clone)] +pub struct PythonExternalFns { + /// Function names callable from Python (e.g., `"call_tool"`). + names: Vec, + /// Async handler invoked when Python calls one of these functions. + handler: PythonExternalFnHandler, +} + +impl std::fmt::Debug for PythonExternalFns { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PythonExternalFns") + .field("names", &self.names) + .field("handler", &"") + .finish() + } +} + /// The python/python3 builtin command. /// /// Executes Python code using the embedded Monty interpreter (pydantic/monty). @@ -126,6 +163,8 @@ impl PythonLimits { pub struct Python { /// Resource limits for the Monty interpreter. pub limits: PythonLimits, + /// Optional external function configuration. + external_fns: Option, } impl Python { @@ -133,12 +172,29 @@ impl Python { pub fn new() -> Self { Self { limits: PythonLimits::default(), + external_fns: None, } } /// Create with custom limits. pub fn with_limits(limits: PythonLimits) -> Self { - Self { limits } + Self { + limits, + external_fns: None, + } + } + + /// Set external function names and handler. + /// + /// External functions are callable from Python by name. + /// When called, execution pauses and the handler is invoked with the raw monty arguments. + pub fn with_external_handler( + mut self, + names: Vec, + handler: PythonExternalFnHandler, + ) -> Self { + self.external_fns = Some(PythonExternalFns { names, handler }); + self } } @@ -263,6 +319,7 @@ impl Builtin for Python { ctx.cwd, ctx.env, &self.limits, + self.external_fns.as_ref(), ) .await } @@ -279,6 +336,7 @@ async fn run_python( cwd: &Path, env: &HashMap, py_limits: &PythonLimits, + external_fns: Option<&PythonExternalFns>, ) -> Result { // Strip shebang if present let code = if code.starts_with("#!") { @@ -290,7 +348,8 @@ async fn run_python( code }; - let runner = match MontyRun::new(code.to_owned(), filename, vec![], vec![]) { + let ext_fn_names = external_fns.map(|ef| ef.names.clone()).unwrap_or_default(); + let runner = match MontyRun::new(code.to_owned(), filename, vec![], ext_fn_names) { Ok(r) => r, Err(e) => return Ok(format_exception(e)), }; @@ -338,14 +397,27 @@ async fn run_python( } } } - RunProgress::FunctionCall { state, .. } => { - // No external functions registered; return error - let err = MontyException::new( - ExcType::RuntimeError, - Some("external function not available in virtual mode".into()), - ); + RunProgress::FunctionCall { + function_name, + args, + kwargs, + state, + .. + } => { + let result = if let Some(ef) = external_fns { + (ef.handler)(function_name, args, kwargs).await + } else { + // No external functions registered; return error + ExternalResult::Error(MontyException::new( + ExcType::RuntimeError, + Some( + "no external function handler configured (external functions not enabled)".into(), + ), + )) + }; + let mut printer = PrintWriter::Collect(buf); - match state.run(ExternalResult::Error(err), &mut printer) { + match state.run(result, &mut printer) { Ok(next) => { buf = take_collected(&mut printer); progress = next; @@ -1106,4 +1178,165 @@ mod tests { assert_eq!(limits.max_memory, 64 * 1024 * 1024); assert_eq!(limits.max_recursion, 200); } + + // --- External function tests --- + + /// Helper: run Python with an external function handler. + async fn run_with_external( + code: &str, + fn_names: &[&str], + handler: PythonExternalFnHandler, + ) -> ExecResult { + let args = vec!["-c".to_string(), code.to_string()]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let py = Python::with_limits(PythonLimits::default()) + .with_external_handler(fn_names.iter().map(|s| s.to_string()).collect(), handler); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + py.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_external_fn_return_value() { + let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| { + Box::pin(async { ExternalResult::Return(MontyObject::Int(42)) }) + }); + let r = run_with_external("print(get_answer())", &["get_answer"], handler).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "42\n"); + } + + #[tokio::test] + async fn test_external_fn_with_args() { + let handler: PythonExternalFnHandler = Arc::new(|_name, args, _kwargs| { + Box::pin(async move { + let a = match &args[0] { + MontyObject::Int(i) => *i, + _ => 0, + }; + let b = match &args[1] { + MontyObject::Int(i) => *i, + _ => 0, + }; + ExternalResult::Return(MontyObject::Int(a + b)) + }) + }); + let r = run_with_external("print(add(3, 4))", &["add"], handler).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "7\n"); + } + + #[tokio::test] + async fn test_external_fn_with_kwargs() { + let handler: PythonExternalFnHandler = Arc::new(|_name, _args, kwargs| { + Box::pin(async move { + for (k, v) in &kwargs { + if let (MontyObject::String(key), MontyObject::String(val)) = (k, v) { + if key == "name" { + return ExternalResult::Return(MontyObject::String(format!( + "hello {val}" + ))); + } + } + } + ExternalResult::Return(MontyObject::String("hello unknown".into())) + }) + }); + let r = run_with_external("print(greet(name='world'))", &["greet"], handler).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello world\n"); + } + + #[tokio::test] + async fn test_external_fn_error() { + let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| { + Box::pin(async { + ExternalResult::Error(MontyException::new( + ExcType::RuntimeError, + Some("something went wrong".into()), + )) + }) + }); + let r = run_with_external("fail()", &["fail"], handler).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("RuntimeError")); + assert!(r.stderr.contains("something went wrong")); + } + + #[tokio::test] + async fn test_external_fn_caught_error() { + let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| { + Box::pin(async { + ExternalResult::Error(MontyException::new( + ExcType::ValueError, + Some("bad value".into()), + )) + }) + }); + let r = run_with_external( + "try:\n fail()\nexcept ValueError as e:\n print(f'caught: {e}')", + &["fail"], + handler, + ) + .await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("caught:")); + assert!(r.stdout.contains("bad value")); + } + + #[tokio::test] + async fn test_external_fn_multiple_calls() { + let counter = Arc::new(std::sync::atomic::AtomicI64::new(0)); + let counter_clone = counter.clone(); + let handler: PythonExternalFnHandler = Arc::new(move |_name, _args, _kwargs| { + let c = counter_clone.clone(); + Box::pin(async move { + let val = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ExternalResult::Return(MontyObject::Int(val)) + }) + }); + let r = run_with_external( + "a = next_id()\nb = next_id()\nc = next_id()\nprint(a, b, c)", + &["next_id"], + handler, + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "0 1 2\n"); + } + + #[tokio::test] + async fn test_external_fn_returns_string() { + let handler: PythonExternalFnHandler = Arc::new(|_name, args, _kwargs| { + Box::pin(async move { + let input = match &args[0] { + MontyObject::String(s) => s.clone(), + _ => String::new(), + }; + ExternalResult::Return(MontyObject::String(input.to_uppercase())) + }) + }); + let r = run_with_external("print(upper('hello'))", &["upper"], handler).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "HELLO\n"); + } + + #[tokio::test] + async fn test_external_fn_dispatches_by_name() { + let handler: PythonExternalFnHandler = Arc::new(|name, _args, _kwargs| { + Box::pin(async move { + let result = match name.as_str() { + "get_x" => MontyObject::Int(10), + "get_y" => MontyObject::Int(20), + _ => MontyObject::None, + }; + ExternalResult::Return(result) + }) + }); + let r = run_with_external("print(get_x() + get_y())", &["get_x", "get_y"], handler).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "30\n"); + } } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 6a747ba5..6346ea58 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -343,6 +343,7 @@ //! - `agent_tool.rs` - LLM agent integration //! - `git_workflow.rs` - Git operations on the virtual filesystem //! - `python_scripts.rs` - Embedded Python with VFS bridging +//! - `python_external_functions.rs` - Python callbacks into host functions //! //! # Guides //! @@ -402,7 +403,12 @@ pub use network::HttpClient; pub use git::GitClient; #[cfg(feature = "python")] -pub use builtins::PythonLimits; +pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits}; +// Re-export monty types needed by external handler consumers. +// **Unstable:** These types come from monty (git-pinned, not on crates.io). +// They may change in breaking ways between bashkit releases. +#[cfg(feature = "python")] +pub use monty::{ExcType, ExternalResult, MontyException, MontyObject}; /// Logging utilities module /// @@ -968,6 +974,31 @@ impl BashBuilder { .builtin("python3", Box::new(builtins::Python::with_limits(limits))) } + /// Enable embedded Python with external function handlers. + /// + /// See [`PythonExternalFnHandler`] for handler details. + #[cfg(feature = "python")] + pub fn python_with_external_handler( + self, + limits: builtins::PythonLimits, + external_fns: Vec, + handler: builtins::PythonExternalFnHandler, + ) -> Self { + self.builtin( + "python", + Box::new( + builtins::Python::with_limits(limits.clone()) + .with_external_handler(external_fns.clone(), handler.clone()), + ), + ) + .builtin( + "python3", + Box::new( + builtins::Python::with_limits(limits).with_external_handler(external_fns, handler), + ), + ) + } + /// Register a custom builtin command. /// /// Custom builtins extend bashkit with domain-specific commands that can be diff --git a/specs/011-python-builtin.md b/specs/011-python-builtin.md index 72649988..90d9f49a 100644 --- a/specs/011-python-builtin.md +++ b/specs/011-python-builtin.md @@ -194,6 +194,52 @@ let bash = Bash::builder() .build(); ``` +### External Functions + +Host applications can register async external function handlers that Python code +can call by name. This enables Python scripts to invoke host-provided capabilities +(e.g., tool calls, data lookups) without serialization overhead — arguments arrive +as raw `MontyObject` values. + +**Builder API:** + +```rust +use bashkit::{Bash, PythonLimits, PythonExternalFnHandler}; +use bashkit::{MontyObject, ExternalResult}; +use std::sync::Arc; + +let handler: PythonExternalFnHandler = Arc::new(|name, args, kwargs| { + Box::pin(async move { + ExternalResult::Return(MontyObject::Int(42)) + }) +}); + +let bash = Bash::builder() + .python_with_external_handler( + PythonLimits::default(), + vec!["get_answer".into()], + handler, + ) + .build(); +``` + +**Handler signature:** `(function_name: String, positional_args: Vec, keyword_args: Vec<(MontyObject, MontyObject)>) -> Pin + Send>>` + +**Return values:** +- `ExternalResult::Return(MontyObject)` — success, value returned to Python +- `ExternalResult::Error(MontyException)` — raises a Python exception + +**Dispatch:** A single handler receives all registered function names; dispatch by +`function_name` inside the handler. + +**Trust model:** External function handlers follow the same trust model as +`BashBuilder::builtin()` and `ScriptedTool` callbacks — the host application +registers trusted Rust code, untrusted scripts invoke it by name. + +**Unstable re-exports:** `MontyObject`, `ExternalResult`, `MontyException`, and +`ExcType` are re-exported from the `monty` crate (git-pinned, not on crates.io). +These types may change in breaking ways between bashkit releases. + ### Security See `specs/006-threat-model.md` section "Python / Monty Security (TM-PY)" diff --git a/supply-chain/config.toml b/supply-chain/config.toml index c77ff225..1a5ddfa2 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -898,6 +898,10 @@ criteria = "safe-to-deploy" version = "1.0.44" criteria = "safe-to-deploy" +[[exemptions.quote]] +version = "1.0.45" +criteria = "safe-to-deploy" + [[exemptions.quote-use]] version = "0.8.4" criteria = "safe-to-deploy"