diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a73404..f44f17a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Script spawner seam (`host-caps`) + +`caps::host::script` now launches every `code`/`shell` script through a +`ScriptSpawner`. `install_script_spawner` lets a host route scripts through its +jail; `require_script_spawner(true)` makes a run with no spawner fail closed. +The default `DirectSpawner` keeps the previous unjailed behaviour. + ### Storage moves entirely onto the `tinystoragedrivers` ports tinyflows no longer ships any storage backend of its own. Every durable piece is diff --git a/crates/tinyflows/src/caps/host/mod.rs b/crates/tinyflows/src/caps/host/mod.rs index 2cc854c1..bc75bd03 100644 --- a/crates/tinyflows/src/caps/host/mod.rs +++ b/crates/tinyflows/src/caps/host/mod.rs @@ -47,8 +47,10 @@ pub use self::http::{ }; pub use self::mocks::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm, sample_for_schema}; pub use self::script::{ - DEFAULT_SHELL, INPUT_ENV, Interpreter, ScriptCompletion, ScriptLanguage, ScriptOutput, - ScriptRequest, ScriptSource, USER_SHELL, run_script, run_script_capture, + DEFAULT_SHELL, DirectSpawner, INPUT_ENV, Interpreter, ScriptCompletion, ScriptLanguage, + ScriptOutput, ScriptRequest, ScriptSource, ScriptSpawner, SpawnOutput, SpawnSpec, USER_SHELL, + clear_script_spawner, install_script_spawner, require_script_spawner, run_script, + run_script_capture, }; pub use self::script_policy::{ScriptPolicy, is_valid_env_name, read_env}; pub use self::shell::ProcessShellRunner; diff --git a/crates/tinyflows/src/caps/host/script.rs b/crates/tinyflows/src/caps/host/script.rs index 7b513970..a8529507 100644 --- a/crates/tinyflows/src/caps/host/script.rs +++ b/crates/tinyflows/src/caps/host/script.rs @@ -18,7 +18,8 @@ //! //! # What this is not //! -//! Not a sandbox. The child inherits this process's environment and privileges, +//! Not a sandbox by itself. A host installs a [`ScriptSpawner`] to run scripts +//! inside its own jail; see [`install_script_spawner`]. By default the child inherits this process's environment and privileges, //! and the only boundary is a temporary working directory, which is not one. //! What *is* checked, at the boundary above this one, is where a script may come //! from and where it may run: see [`super::script_policy`]. A host decides @@ -344,7 +345,12 @@ pub struct ScriptCompletion { } mod runner; +mod spawner; pub use runner::{run_script, run_script_capture}; +pub use spawner::{ + DirectSpawner, ScriptSpawner, SpawnOutput, SpawnSpec, clear_script_spawner, + install_script_spawner, require_script_spawner, +}; #[cfg(test)] #[path = "script_tests.rs"] diff --git a/crates/tinyflows/src/caps/host/script/runner.rs b/crates/tinyflows/src/caps/host/script/runner.rs index db9672a4..3fb9b36c 100644 --- a/crates/tinyflows/src/caps/host/script/runner.rs +++ b/crates/tinyflows/src/caps/host/script/runner.rs @@ -58,8 +58,6 @@ pub async fn run_script(request: ScriptRequest<'_>) -> Result { /// on disk, or the script outlives `request.timeout` — that is, when there is no /// exit status to report at all. pub async fn run_script_capture(request: ScriptRequest<'_>) -> Result { - use tokio::io::AsyncWriteExt; - let ScriptRequest { language, interpreter, @@ -115,67 +113,37 @@ pub async fn run_script_capture(request: ScriptRequest<'_>) -> Result = leading_args.to_vec(); + args.push(script.to_string_lossy().into_owned()); + args.push(input_path.to_string_lossy().into_owned()); + // Layered after `INPUT_ENV` so a workflow's own declaration wins over the + // inherited value of the same name. + let mut spawn_env = BTreeMap::new(); + spawn_env.insert( + INPUT_ENV.to_string(), + input_path.to_string_lossy().into_owned(), + ); + spawn_env.extend(env.iter().map(|(k, v)| (k.clone(), v.clone()))); + tracing::debug!(program, "script: spawning"); + let output = spawner + .spawn(spawner::SpawnSpec { + program: program.to_string(), + args, + env: spawn_env, + cwd: cwd.map_or_else(|| dir.path().to_path_buf(), Path::to_path_buf), + staging_dir: dir.path().to_path_buf(), + stdin: body, + timeout, + }) + .await?; Ok(ScriptCompletion { // `-1` for a signal: `ExitStatus::code()` is `None` when a process was // terminated rather than exiting, and a caller comparing against zero // must not read that as success. - exit_code: output.status.code().unwrap_or(-1), + exit_code: output.exit_code, stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), }) diff --git a/crates/tinyflows/src/caps/host/script/spawner.rs b/crates/tinyflows/src/caps/host/script/spawner.rs new file mode 100644 index 00000000..1e016717 --- /dev/null +++ b/crates/tinyflows/src/caps/host/script/spawner.rs @@ -0,0 +1,169 @@ +//! The seam a host uses to put script processes inside its own jail. +//! +//! The runner stages the script and its input on disk, then hands one +//! [`SpawnSpec`] to a [`ScriptSpawner`]. The default [`DirectSpawner`] runs the +//! interpreter as a plain child of this process (the historical behaviour). A +//! host with a sandbox installs its own spawner with [`install_script_spawner`] +//! so every script, `code` and `shell` alike, runs through the jail. +//! +//! A host that must never run an unjailed script also calls +//! [`require_script_spawner`]: from then on a run with no installed spawner +//! fails closed instead of falling back to [`DirectSpawner`]. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use async_trait::async_trait; + +use super::*; + +/// One fully staged process launch. +#[derive(Debug, Clone)] +pub struct SpawnSpec { + /// The program to run: a bare name or an absolute path. + pub program: String, + /// Every argument, in order: the interpreter's leading arguments, then the + /// script path, then the input path. + pub args: Vec, + /// Environment layered over the inherited one. + pub env: BTreeMap, + /// The working directory. + pub cwd: PathBuf, + /// The directory holding the staged script and `input.json`. A jail must + /// make it readable inside the box. + pub staging_dir: PathBuf, + /// Bytes written to the process's stdin. + pub stdin: Vec, + /// How long the process may run. + pub timeout: Duration, +} + +/// What a spawned process produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpawnOutput { + /// Exit status, or `-1` when a signal terminated it. + pub exit_code: i32, + /// Raw stdout. + pub stdout: Vec, + /// Raw stderr. + pub stderr: Vec, +} + +/// Launches a staged script process, possibly inside a jail. +#[async_trait] +pub trait ScriptSpawner: Send + Sync { + /// Run `spec` to completion. + /// + /// # Errors + /// + /// Fails when the process cannot be launched (or the jail refuses it) or it + /// outlives `spec.timeout`. + async fn spawn(&self, spec: SpawnSpec) -> Result; +} + +/// Runs the interpreter as an unjailed child of this process. +#[derive(Debug, Default, Clone, Copy)] +pub struct DirectSpawner; + +#[async_trait] +impl ScriptSpawner for DirectSpawner { + async fn spawn(&self, spec: SpawnSpec) -> Result { + use tokio::io::AsyncWriteExt; + + let SpawnSpec { + program, + args, + env, + cwd, + stdin: body, + timeout, + .. + } = spec; + let mut command = tokio::process::Command::new(&program); + command + .args(&args) + .envs(&env) + .current_dir(&cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + // Tokio does not kill a child when its future is dropped, so a + // timeout would otherwise leave an infinite script running. + .kill_on_drop(true); + let mut child = command.spawn().map_err(|err| { + EngineError::Capability(format!( + "script: cannot run `{program}` ({err}). Is it installed and on PATH?" + )) + })?; + // Stdin is written on its own task while stdout/stderr drain, so a + // script that prints before reading all of stdin cannot deadlock. + let mut stdin = child.stdin.take(); + let writer = tokio::spawn(async move { + if let Some(mut stdin) = stdin.take() { + let _ = stdin.write_all(&body).await; + let _ = stdin.shutdown().await; + } + }); + let output = tokio::time::timeout(timeout, async { + let output = child.wait_with_output().await; + let _ = writer.await; + output + }) + .await + .map_err(|_| { + EngineError::Capability(format!("script: timed out after {}s", timeout.as_secs())) + })? + .map_err(|err| EngineError::Capability(format!("script: {program}: {err}")))?; + Ok(SpawnOutput { + exit_code: output.status.code().unwrap_or(-1), + stdout: output.stdout, + stderr: output.stderr, + }) + } +} + +struct SpawnerState { + installed: Option>, + required: bool, +} + +static STATE: RwLock = RwLock::new(SpawnerState { + installed: None, + required: false, +}); + +/// Route every script this process runs through `spawner`. +pub fn install_script_spawner(spawner: Arc) { + tracing::debug!("script spawner installed"); + STATE.write().unwrap_or_else(|e| e.into_inner()).installed = Some(spawner); +} + +/// Remove the installed spawner, returning to [`DirectSpawner`]. +pub fn clear_script_spawner() { + STATE.write().unwrap_or_else(|e| e.into_inner()).installed = None; +} + +/// Fail closed: while `required`, a script run with no installed spawner is +/// refused rather than run unjailed. +pub fn require_script_spawner(required: bool) { + STATE.write().unwrap_or_else(|e| e.into_inner()).required = required; +} + +/// The spawner a run should use. +/// +/// # Errors +/// +/// Fails when a spawner is required and none is installed. +pub(super) fn current_spawner() -> Result> { + let state = STATE.read().unwrap_or_else(|e| e.into_inner()); + match (&state.installed, state.required) { + (Some(spawner), _) => Ok(Arc::clone(spawner)), + (None, true) => Err(EngineError::Capability( + "script: refused, a script spawner is required but none is installed (fail closed)" + .to_string(), + )), + (None, false) => Ok(Arc::new(DirectSpawner)), + } +} diff --git a/crates/tinyflows/tests/script_spawner_e2e.rs b/crates/tinyflows/tests/script_spawner_e2e.rs new file mode 100644 index 00000000..bc8d66f1 --- /dev/null +++ b/crates/tinyflows/tests/script_spawner_e2e.rs @@ -0,0 +1,93 @@ +//! The script spawner seam: a host jail receives every script launch, and a +//! required spawner fails closed. Own test binary because the seam is process-global. +#![cfg(feature = "host-caps")] + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinyflows::caps::host::*; +use tinyflows::error::Result; + +/// The spawner state is process-global, so these cases run one at a time. +static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +struct Recording(Mutex>); + +#[async_trait] +impl ScriptSpawner for Recording { + async fn spawn(&self, spec: SpawnSpec) -> Result { + self.0.lock().unwrap().push(spec); + Ok(SpawnOutput { + exit_code: 0, + stdout: b"{\"jailed\":true}".to_vec(), + stderr: Vec::new(), + }) + } +} + +fn request<'a>(input: &'a Value, env: &'a BTreeMap) -> ScriptRequest<'a> { + ScriptRequest::plain( + ScriptLanguage::Python, + "print(1)", + input, + Duration::from_secs(5), + env, + ) +} + +#[tokio::test] +async fn an_installed_spawner_receives_the_staged_script() { + let _guard = SERIAL.lock().await; + let recording = Arc::new(Recording(Mutex::new(Vec::new()))); + install_script_spawner(recording.clone()); + let env = BTreeMap::from([("A".to_string(), "b".to_string())]); + let input = json!({"n": 1}); + let output = run_script(request(&input, &env)).await.expect("runs"); + clear_script_spawner(); + + assert_eq!(output.value, json!({"jailed": true})); + let specs = recording.0.lock().unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].program, "python3"); + assert_eq!(specs[0].args.len(), 2); + assert_eq!(specs[0].env.get("A").map(String::as_str), Some("b")); + assert!(specs[0].env.contains_key(INPUT_ENV)); + assert_eq!(specs[0].stdin, b"{\"n\":1}"); +} + +#[tokio::test] +async fn a_required_spawner_fails_closed_when_none_is_installed() { + let _guard = SERIAL.lock().await; + clear_script_spawner(); + require_script_spawner(true); + let env = BTreeMap::new(); + let input = json!(null); + let result = run_script(request(&input, &env)).await; + require_script_spawner(false); + + let message = result.expect_err("refused").to_string(); + assert!(message.contains("fail closed"), "{message}"); +} + +#[cfg(unix)] +#[tokio::test] +async fn without_a_spawner_scripts_still_run_directly() { + let _guard = SERIAL.lock().await; + clear_script_spawner(); + require_script_spawner(false); + let env = BTreeMap::new(); + let input = json!({"x": 2}); + let output = run_script(ScriptRequest::plain( + ScriptLanguage::Shell, + "cat", + &input, + Duration::from_secs(5), + &env, + )) + .await + .expect("runs"); + assert_eq!(output.value, input); +}