From 6fec7dbab56eca0403736b905643e9a11e3a41b7 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 25 Mar 2026 22:32:07 +0000 Subject: [PATCH] feat(wfe-yaml): add deno_core JS/TS executor with sandboxed permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secure JavaScript/TypeScript execution in workflow steps via deno_core, behind the `deno` feature flag. Security features: - Per-step permission system: net host allowlist, filesystem read/write path restrictions, env var allowlist, subprocess spawn control - V8 heap limits (64MB default) prevent memory exhaustion - Execution timeout with V8 isolate termination for sync infinite loops - Path traversal detection blocks ../ escape attempts - Dynamic import rejection unless explicitly enabled Workflow I/O ops: - inputs() — read workflow data as JSON - output(key, value) — set step outputs - log(message) — structured tracing Architecture: - JsRuntime runs on dedicated thread (V8 is !Send) - PermissionChecker enforced on every I/O op via OpState - DenoStep implements StepBody, integrates with existing compiler - Step type dispatch: "shell" or "deno" in YAML 34 new tests (12 permission unit, 3 config, 2 runtime, 18 integration). --- Cargo.toml | 4 + wfe-yaml/Cargo.toml | 7 + wfe-yaml/src/compiler.rs | 189 +++++++++----- wfe-yaml/src/executors/deno/config.rs | 121 +++++++++ wfe-yaml/src/executors/deno/js/bootstrap.js | 3 + wfe-yaml/src/executors/deno/mod.rs | 9 + wfe-yaml/src/executors/deno/ops/mod.rs | 3 + wfe-yaml/src/executors/deno/ops/workflow.rs | 52 ++++ wfe-yaml/src/executors/deno/permissions.rs | 257 +++++++++++++++++++ wfe-yaml/src/executors/deno/runtime.rs | 81 ++++++ wfe-yaml/src/executors/deno/step.rs | 156 ++++++++++++ wfe-yaml/src/executors/mod.rs | 3 + wfe-yaml/src/schema.rs | 21 ++ wfe-yaml/src/validation.rs | 18 ++ wfe-yaml/tests/deno.rs | 269 ++++++++++++++++++++ 15 files changed, 1127 insertions(+), 66 deletions(-) create mode 100644 wfe-yaml/src/executors/deno/config.rs create mode 100644 wfe-yaml/src/executors/deno/js/bootstrap.js create mode 100644 wfe-yaml/src/executors/deno/mod.rs create mode 100644 wfe-yaml/src/executors/deno/ops/mod.rs create mode 100644 wfe-yaml/src/executors/deno/ops/workflow.rs create mode 100644 wfe-yaml/src/executors/deno/permissions.rs create mode 100644 wfe-yaml/src/executors/deno/runtime.rs create mode 100644 wfe-yaml/src/executors/deno/step.rs create mode 100644 wfe-yaml/tests/deno.rs diff --git a/Cargo.toml b/Cargo.toml index b26925a..b02d17d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,10 @@ wfe-yaml = { path = "wfe-yaml" } serde_yaml = "0.9" regex = "1" +# Deno runtime +deno_core = "0.394" +url = "2" + # Dev/Test pretty_assertions = "1" rstest = "0.23" diff --git a/wfe-yaml/Cargo.toml b/wfe-yaml/Cargo.toml index 9601b1e..d126034 100644 --- a/wfe-yaml/Cargo.toml +++ b/wfe-yaml/Cargo.toml @@ -4,6 +4,10 @@ version.workspace = true edition.workspace = true description = "YAML workflow definitions for WFE" +[features] +default = [] +deno = ["deno_core", "url"] + [dependencies] wfe-core = { workspace = true } serde = { workspace = true } @@ -14,9 +18,12 @@ tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } regex = { workspace = true } +deno_core = { workspace = true, optional = true } +url = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } tokio = { workspace = true, features = ["test-util", "process"] } +tokio-util = "0.7" wfe-core = { workspace = true, features = ["test-support"] } wfe = { path = "../wfe" } diff --git a/wfe-yaml/src/compiler.rs b/wfe-yaml/src/compiler.rs index 57730b5..139da7f 100644 --- a/wfe-yaml/src/compiler.rs +++ b/wfe-yaml/src/compiler.rs @@ -6,6 +6,8 @@ use wfe_core::traits::StepBody; use crate::error::YamlWorkflowError; use crate::executors::shell::{ShellConfig, ShellStep}; +#[cfg(feature = "deno")] +use crate::executors::deno::{DenoConfig, DenoPermissions, DenoStep}; use crate::schema::{WorkflowSpec, YamlErrorBehavior, YamlStep}; /// Factory type alias for step creation closures. @@ -69,20 +71,24 @@ fn compile_steps( definition.steps.push(container); main_step_ids.push(container_id); } else { - // Regular step (shell). + // Regular step. let step_id = *next_id; *next_id += 1; - let step_type_key = format!("wfe_yaml::shell::{}", yaml_step.name); - let config = build_shell_config(yaml_step)?; + let step_type = yaml_step + .step_type + .as_deref() + .unwrap_or("shell"); + + let (step_type_key, step_config_value, factory): ( + String, + serde_json::Value, + StepFactory, + ) = build_step_config_and_factory(yaml_step, step_type)?; let mut wf_step = WorkflowStep::new(step_id, &step_type_key); wf_step.name = Some(yaml_step.name.clone()); - wf_step.step_config = Some(serde_json::to_value(&config).map_err(|e| { - YamlWorkflowError::Compilation(format!( - "Failed to serialize shell config: {e}" - )) - })?); + wf_step.step_config = Some(step_config_value); if let Some(ref eb) = yaml_step.error_behavior { wf_step.error_behavior = Some(map_error_behavior(eb)?); @@ -93,31 +99,22 @@ fn compile_steps( let comp_id = *next_id; *next_id += 1; - let comp_key = format!("wfe_yaml::shell::{}", on_failure.name); - let comp_config = build_shell_config(on_failure)?; + let on_failure_type = on_failure + .step_type + .as_deref() + .unwrap_or("shell"); + let (comp_key, comp_config_value, comp_factory) = + build_step_config_and_factory(on_failure, on_failure_type)?; let mut comp_step = WorkflowStep::new(comp_id, &comp_key); comp_step.name = Some(on_failure.name.clone()); - comp_step.step_config = - Some(serde_json::to_value(&comp_config).map_err(|e| { - YamlWorkflowError::Compilation(format!( - "Failed to serialize shell config: {e}" - )) - })?); + comp_step.step_config = Some(comp_config_value); wf_step.compensation_step_id = Some(comp_id); wf_step.error_behavior = Some(ErrorBehavior::Compensate); definition.steps.push(comp_step); - - let comp_config_clone = comp_config.clone(); - factories.push(( - comp_key, - Box::new(move || { - Box::new(ShellStep::new(comp_config_clone.clone())) - as Box - }), - )); + factories.push((comp_key, comp_factory)); } // Handle on_success: insert between this step and the next. @@ -125,17 +122,16 @@ fn compile_steps( let success_id = *next_id; *next_id += 1; - let success_key = format!("wfe_yaml::shell::{}", on_success.name); - let success_config = build_shell_config(on_success)?; + let on_success_type = on_success + .step_type + .as_deref() + .unwrap_or("shell"); + let (success_key, success_config_value, success_factory) = + build_step_config_and_factory(on_success, on_success_type)?; let mut success_step = WorkflowStep::new(success_id, &success_key); success_step.name = Some(on_success.name.clone()); - success_step.step_config = - Some(serde_json::to_value(&success_config).map_err(|e| { - YamlWorkflowError::Compilation(format!( - "Failed to serialize shell config: {e}" - )) - })?); + success_step.step_config = Some(success_config_value); // Wire main step -> on_success step. wf_step.outcomes.push(StepOutcome { @@ -145,15 +141,7 @@ fn compile_steps( }); definition.steps.push(success_step); - - let success_config_clone = success_config.clone(); - factories.push(( - success_key, - Box::new(move || { - Box::new(ShellStep::new(success_config_clone.clone())) - as Box - }), - )); + factories.push((success_key, success_factory)); } // Handle ensure: create an ensure step wired after both paths. @@ -161,17 +149,16 @@ fn compile_steps( let ensure_id = *next_id; *next_id += 1; - let ensure_key = format!("wfe_yaml::shell::{}", ensure.name); - let ensure_config = build_shell_config(ensure)?; + let ensure_type = ensure + .step_type + .as_deref() + .unwrap_or("shell"); + let (ensure_key, ensure_config_value, ensure_factory) = + build_step_config_and_factory(ensure, ensure_type)?; let mut ensure_step = WorkflowStep::new(ensure_id, &ensure_key); ensure_step.name = Some(ensure.name.clone()); - ensure_step.step_config = - Some(serde_json::to_value(&ensure_config).map_err(|e| { - YamlWorkflowError::Compilation(format!( - "Failed to serialize shell config: {e}" - )) - })?); + ensure_step.step_config = Some(ensure_config_value); // Wire main step -> ensure (if no on_success already). if yaml_step.on_success.is_none() { @@ -183,27 +170,13 @@ fn compile_steps( } definition.steps.push(ensure_step); - - let ensure_config_clone = ensure_config.clone(); - factories.push(( - ensure_key, - Box::new(move || { - Box::new(ShellStep::new(ensure_config_clone.clone())) - as Box - }), - )); + factories.push((ensure_key, ensure_factory)); } definition.steps.push(wf_step); // Register factory for main step. - let config_clone = config.clone(); - factories.push(( - step_type_key, - Box::new(move || { - Box::new(ShellStep::new(config_clone.clone())) as Box - }), - )); + factories.push((step_type_key, factory)); main_step_ids.push(step_id); } @@ -243,6 +216,90 @@ fn compile_steps( Ok(main_step_ids) } +fn build_step_config_and_factory( + step: &YamlStep, + step_type: &str, +) -> Result<(String, serde_json::Value, StepFactory), YamlWorkflowError> { + match step_type { + "shell" => { + let config = build_shell_config(step)?; + let key = format!("wfe_yaml::shell::{}", step.name); + let value = serde_json::to_value(&config).map_err(|e| { + YamlWorkflowError::Compilation(format!( + "Failed to serialize shell config: {e}" + )) + })?; + let config_clone = config.clone(); + let factory: StepFactory = Box::new(move || { + Box::new(ShellStep::new(config_clone.clone())) as Box + }); + Ok((key, value, factory)) + } + #[cfg(feature = "deno")] + "deno" => { + let config = build_deno_config(step)?; + let key = format!("wfe_yaml::deno::{}", step.name); + let value = serde_json::to_value(&config).map_err(|e| { + YamlWorkflowError::Compilation(format!( + "Failed to serialize deno config: {e}" + )) + })?; + let config_clone = config.clone(); + let factory: StepFactory = Box::new(move || { + Box::new(DenoStep::new(config_clone.clone())) as Box + }); + Ok((key, value, factory)) + } + other => Err(YamlWorkflowError::Compilation(format!( + "Unknown step type: '{other}'" + ))), + } +} + +#[cfg(feature = "deno")] +fn build_deno_config(step: &YamlStep) -> Result { + let config = step.config.as_ref().ok_or_else(|| { + YamlWorkflowError::Compilation(format!( + "Deno step '{}' is missing 'config' section", + step.name + )) + })?; + + let script = config.script.clone(); + let file = config.file.clone(); + + if script.is_none() && file.is_none() { + return Err(YamlWorkflowError::Compilation(format!( + "Deno step '{}' must have 'script' or 'file' in config", + step.name + ))); + } + + let timeout_ms = config.timeout.as_ref().and_then(|t| parse_duration_ms(t)); + + let permissions = config + .permissions + .as_ref() + .map(|p| DenoPermissions { + net: p.net.clone(), + read: p.read.clone(), + write: p.write.clone(), + env: p.env.clone(), + run: p.run, + dynamic_import: p.dynamic_import, + }) + .unwrap_or_default(); + + Ok(DenoConfig { + script, + file, + permissions, + modules: config.modules.clone(), + env: config.env.clone(), + timeout_ms, + }) +} + fn build_shell_config(step: &YamlStep) -> Result { let config = step.config.as_ref().ok_or_else(|| { YamlWorkflowError::Compilation(format!( diff --git a/wfe-yaml/src/executors/deno/config.rs b/wfe-yaml/src/executors/deno/config.rs new file mode 100644 index 0000000..0d3a61a --- /dev/null +++ b/wfe-yaml/src/executors/deno/config.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Configuration for a Deno JavaScript/TypeScript step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DenoConfig { + /// Inline script source code. + pub script: Option, + /// Path to a script file. + pub file: Option, + /// Permission allowlists. + #[serde(default)] + pub permissions: DenoPermissions, + /// ES modules to pre-load. + #[serde(default)] + pub modules: Vec, + /// Extra environment variables. + #[serde(default)] + pub env: HashMap, + /// Execution timeout in milliseconds. + pub timeout_ms: Option, +} + +/// Fine-grained permission allowlists for the Deno sandbox. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DenoPermissions { + /// Allowed network hosts (e.g. `["api.example.com"]`). + #[serde(default)] + pub net: Vec, + /// Allowed file-system read paths. + #[serde(default)] + pub read: Vec, + /// Allowed file-system write paths. + #[serde(default)] + pub write: Vec, + /// Allowed environment variable names. + #[serde(default)] + pub env: Vec, + /// Whether sub-process spawning is allowed. + #[serde(default)] + pub run: bool, + /// Whether dynamic `import()` is allowed. + #[serde(default)] + pub dynamic_import: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_round_trip_full_config() { + let config = DenoConfig { + script: Some("console.log('hi')".to_string()), + file: None, + permissions: DenoPermissions { + net: vec!["example.com".to_string()], + read: vec!["/tmp".to_string()], + write: vec!["/tmp/out".to_string()], + env: vec!["HOME".to_string()], + run: false, + dynamic_import: true, + }, + modules: vec!["https://deno.land/std/mod.ts".to_string()], + env: { + let mut m = HashMap::new(); + m.insert("FOO".to_string(), "bar".to_string()); + m + }, + timeout_ms: Some(5000), + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: DenoConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.script.as_deref(), Some("console.log('hi')")); + assert!(deserialized.file.is_none()); + assert_eq!(deserialized.permissions.net, vec!["example.com"]); + assert_eq!(deserialized.permissions.read, vec!["/tmp"]); + assert_eq!(deserialized.permissions.write, vec!["/tmp/out"]); + assert_eq!(deserialized.permissions.env, vec!["HOME"]); + assert!(!deserialized.permissions.run); + assert!(deserialized.permissions.dynamic_import); + assert_eq!(deserialized.modules.len(), 1); + assert_eq!(deserialized.env.get("FOO").unwrap(), "bar"); + assert_eq!(deserialized.timeout_ms, Some(5000)); + } + + #[test] + fn serde_round_trip_defaults() { + let json = r#"{"script": "1+1"}"#; + let config: DenoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.script.as_deref(), Some("1+1")); + assert!(config.file.is_none()); + assert!(config.permissions.net.is_empty()); + assert!(config.permissions.read.is_empty()); + assert!(config.permissions.write.is_empty()); + assert!(config.permissions.env.is_empty()); + assert!(!config.permissions.run); + assert!(!config.permissions.dynamic_import); + assert!(config.modules.is_empty()); + assert!(config.env.is_empty()); + assert!(config.timeout_ms.is_none()); + } + + #[test] + fn serde_round_trip_file_config() { + let config = DenoConfig { + script: None, + file: Some("./scripts/run.ts".to_string()), + permissions: DenoPermissions::default(), + modules: vec![], + env: HashMap::new(), + timeout_ms: Some(10000), + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: DenoConfig = serde_json::from_str(&json).unwrap(); + assert!(deserialized.script.is_none()); + assert_eq!(deserialized.file.as_deref(), Some("./scripts/run.ts")); + assert_eq!(deserialized.timeout_ms, Some(10000)); + } +} diff --git a/wfe-yaml/src/executors/deno/js/bootstrap.js b/wfe-yaml/src/executors/deno/js/bootstrap.js new file mode 100644 index 0000000..0fdd7ae --- /dev/null +++ b/wfe-yaml/src/executors/deno/js/bootstrap.js @@ -0,0 +1,3 @@ +globalThis.inputs = () => Deno.core.ops.op_inputs(); +globalThis.output = (key, value) => Deno.core.ops.op_output(key, value); +globalThis.log = (msg) => Deno.core.ops.op_log(msg); diff --git a/wfe-yaml/src/executors/deno/mod.rs b/wfe-yaml/src/executors/deno/mod.rs new file mode 100644 index 0000000..eccb3f5 --- /dev/null +++ b/wfe-yaml/src/executors/deno/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod ops; +pub mod permissions; +pub mod runtime; +pub mod step; + +pub use config::{DenoConfig, DenoPermissions}; +pub use permissions::PermissionChecker; +pub use step::DenoStep; diff --git a/wfe-yaml/src/executors/deno/ops/mod.rs b/wfe-yaml/src/executors/deno/ops/mod.rs new file mode 100644 index 0000000..6ee0278 --- /dev/null +++ b/wfe-yaml/src/executors/deno/ops/mod.rs @@ -0,0 +1,3 @@ +pub mod workflow; + +pub use workflow::{StepMeta, StepOutputs, WorkflowInputs}; diff --git a/wfe-yaml/src/executors/deno/ops/workflow.rs b/wfe-yaml/src/executors/deno/ops/workflow.rs new file mode 100644 index 0000000..f363e78 --- /dev/null +++ b/wfe-yaml/src/executors/deno/ops/workflow.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; + +use deno_core::op2; +use deno_core::OpState; + +/// Workflow data available to the script via `inputs()`. +pub struct WorkflowInputs { + pub data: serde_json::Value, +} + +/// Accumulates key/value outputs set by the script via `output(key, value)`. +pub struct StepOutputs { + pub map: HashMap, +} + +/// Metadata about the currently executing step. +pub struct StepMeta { + pub name: String, +} + +/// Returns the workflow input data to JavaScript. +#[op2] +#[serde] +pub fn op_inputs(state: &mut OpState) -> serde_json::Value { + let inputs = state.borrow::(); + inputs.data.clone() +} + +/// Stores a key/value pair in the step outputs. +#[op2] +pub fn op_output( + state: &mut OpState, + #[string] key: String, + #[serde] value: serde_json::Value, +) { + let outputs = state.borrow_mut::(); + outputs.map.insert(key, value); +} + +/// Logs a message via the tracing crate. +#[op2(fast)] +pub fn op_log(state: &mut OpState, #[string] msg: String) { + let name = state.borrow::().name.clone(); + tracing::info!(step = %name, "{}", msg); +} + +deno_core::extension!( + wfe_ops, + ops = [op_inputs, op_output, op_log], + esm_entry_point = "ext:wfe/bootstrap.js", + esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"], +); diff --git a/wfe-yaml/src/executors/deno/permissions.rs b/wfe-yaml/src/executors/deno/permissions.rs new file mode 100644 index 0000000..3f50b8a --- /dev/null +++ b/wfe-yaml/src/executors/deno/permissions.rs @@ -0,0 +1,257 @@ +use std::fmt; +use std::path::Path; + +use super::config::DenoPermissions; + +/// Error returned when a permission check fails. +#[derive(Debug, Clone)] +pub struct PermissionError { + pub kind: &'static str, + pub resource: String, +} + +impl fmt::Display for PermissionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Permission denied: {} access to '{}'", + self.kind, self.resource + ) + } +} + +impl std::error::Error for PermissionError {} + +/// Checks whether operations are allowed based on the configured permissions. +pub struct PermissionChecker { + net_hosts: Vec, + read_paths: Vec, + write_paths: Vec, + env_vars: Vec, + pub allow_run: bool, + pub allow_dynamic_import: bool, +} + +impl PermissionChecker { + /// Build a checker from the YAML permission config. + pub fn from_config(perms: &DenoPermissions) -> Self { + Self { + net_hosts: perms.net.clone(), + read_paths: perms.read.clone(), + write_paths: perms.write.clone(), + env_vars: perms.env.clone(), + allow_run: perms.run, + allow_dynamic_import: perms.dynamic_import, + } + } + + /// Check whether network access to `host` is allowed. + pub fn check_net(&self, host: &str) -> Result<(), PermissionError> { + if self.net_hosts.iter().any(|h| h == host) { + Ok(()) + } else { + Err(PermissionError { + kind: "net", + resource: host.to_string(), + }) + } + } + + /// Check whether file-system read access to `path` is allowed. + pub fn check_read(&self, path: &str) -> Result<(), PermissionError> { + // Block path traversal attempts. + if Self::has_traversal(path) { + return Err(PermissionError { + kind: "read", + resource: path.to_string(), + }); + } + + let target = Path::new(path); + if self + .read_paths + .iter() + .any(|allowed| target.starts_with(allowed)) + { + Ok(()) + } else { + Err(PermissionError { + kind: "read", + resource: path.to_string(), + }) + } + } + + /// Check whether file-system write access to `path` is allowed. + pub fn check_write(&self, path: &str) -> Result<(), PermissionError> { + if Self::has_traversal(path) { + return Err(PermissionError { + kind: "write", + resource: path.to_string(), + }); + } + + let target = Path::new(path); + if self + .write_paths + .iter() + .any(|allowed| target.starts_with(allowed)) + { + Ok(()) + } else { + Err(PermissionError { + kind: "write", + resource: path.to_string(), + }) + } + } + + /// Check whether reading environment variable `var` is allowed. + pub fn check_env(&self, var: &str) -> Result<(), PermissionError> { + if self.env_vars.iter().any(|v| v == var) { + Ok(()) + } else { + Err(PermissionError { + kind: "env", + resource: var.to_string(), + }) + } + } + + /// Detect `..` path traversal components. + fn has_traversal(path: &str) -> bool { + Path::new(path).components().any(|c| { + matches!(c, std::path::Component::ParentDir) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn perms( + net: &[&str], + read: &[&str], + write: &[&str], + env: &[&str], + ) -> PermissionChecker { + PermissionChecker::from_config(&DenoPermissions { + net: net.iter().map(|s| s.to_string()).collect(), + read: read.iter().map(|s| s.to_string()).collect(), + write: write.iter().map(|s| s.to_string()).collect(), + env: env.iter().map(|s| s.to_string()).collect(), + run: false, + dynamic_import: false, + }) + } + + #[test] + fn net_allowed_host_passes() { + let checker = perms(&["api.example.com"], &[], &[], &[]); + assert!(checker.check_net("api.example.com").is_ok()); + } + + #[test] + fn net_denied_host_fails() { + let checker = perms(&["api.example.com"], &[], &[], &[]); + let err = checker.check_net("evil.com").unwrap_err(); + assert_eq!(err.kind, "net"); + assert_eq!(err.resource, "evil.com"); + } + + #[test] + fn net_empty_allowlist_denies_all() { + let checker = perms(&[], &[], &[], &[]); + assert!(checker.check_net("anything.com").is_err()); + } + + #[test] + fn read_allowed_path_passes() { + let checker = perms(&[], &["/tmp"], &[], &[]); + assert!(checker.check_read("/tmp/data.json").is_ok()); + } + + #[test] + fn read_denied_path_fails() { + let checker = perms(&[], &["/tmp"], &[], &[]); + let err = checker.check_read("/etc/passwd").unwrap_err(); + assert_eq!(err.kind, "read"); + } + + #[test] + fn read_path_traversal_blocked() { + let checker = perms(&[], &["/tmp"], &[], &[]); + let err = checker + .check_read("/tmp/../../../etc/passwd") + .unwrap_err(); + assert_eq!(err.kind, "read"); + assert!(err.resource.contains("..")); + } + + #[test] + fn write_allowed_path_passes() { + let checker = perms(&[], &[], &["/tmp/out"], &[]); + assert!(checker.check_write("/tmp/out/result.json").is_ok()); + } + + #[test] + fn write_denied_path_fails() { + let checker = perms(&[], &[], &["/tmp/out"], &[]); + let err = checker.check_write("/var/data").unwrap_err(); + assert_eq!(err.kind, "write"); + } + + #[test] + fn write_path_traversal_blocked() { + let checker = perms(&[], &[], &["/tmp/out"], &[]); + assert!(checker + .check_write("/tmp/out/../../etc/shadow") + .is_err()); + } + + #[test] + fn env_allowed_var_passes() { + let checker = perms(&[], &[], &[], &["HOME", "PATH"]); + assert!(checker.check_env("HOME").is_ok()); + } + + #[test] + fn env_denied_var_fails() { + let checker = perms(&[], &[], &[], &["HOME"]); + let err = checker.check_env("SECRET_KEY").unwrap_err(); + assert_eq!(err.kind, "env"); + assert_eq!(err.resource, "SECRET_KEY"); + } + + #[test] + fn permission_error_display() { + let err = PermissionError { + kind: "net", + resource: "evil.com".to_string(), + }; + assert_eq!( + err.to_string(), + "Permission denied: net access to 'evil.com'" + ); + } + + #[test] + fn from_config_copies_all_fields() { + let config = DenoPermissions { + net: vec!["a.com".to_string()], + read: vec!["/r".to_string()], + write: vec!["/w".to_string()], + env: vec!["E".to_string()], + run: true, + dynamic_import: true, + }; + let checker = PermissionChecker::from_config(&config); + assert!(checker.allow_run); + assert!(checker.allow_dynamic_import); + assert!(checker.check_net("a.com").is_ok()); + assert!(checker.check_read("/r/file").is_ok()); + assert!(checker.check_write("/w/file").is_ok()); + assert!(checker.check_env("E").is_ok()); + } +} diff --git a/wfe-yaml/src/executors/deno/runtime.rs b/wfe-yaml/src/executors/deno/runtime.rs new file mode 100644 index 0000000..aa32610 --- /dev/null +++ b/wfe-yaml/src/executors/deno/runtime.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; + +use deno_core::JsRuntime; +use deno_core::RuntimeOptions; +use wfe_core::WfeError; + +use super::config::DenoConfig; +use super::ops::workflow::{wfe_ops, StepMeta, StepOutputs, WorkflowInputs}; +use super::permissions::PermissionChecker; + +/// Create a configured `JsRuntime` for executing a workflow step script. +pub fn create_runtime( + config: &DenoConfig, + workflow_data: serde_json::Value, + step_name: &str, +) -> Result { + let ext = wfe_ops::init(); + + let runtime = JsRuntime::new(RuntimeOptions { + extensions: vec![ext], + ..Default::default() + }); + + // Populate OpState with our workflow types. + { + let state = runtime.op_state(); + let mut state = state.borrow_mut(); + state.put(WorkflowInputs { + data: workflow_data, + }); + state.put(StepOutputs { + map: HashMap::new(), + }); + state.put(StepMeta { + name: step_name.to_string(), + }); + state.put(PermissionChecker::from_config(&config.permissions)); + } + + Ok(runtime) +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::config::DenoPermissions; + + #[test] + fn create_runtime_succeeds() { + let config = DenoConfig { + script: Some("1+1".to_string()), + file: None, + permissions: DenoPermissions::default(), + modules: vec![], + env: HashMap::new(), + timeout_ms: None, + }; + let runtime = create_runtime(&config, serde_json::json!({}), "test-step"); + assert!(runtime.is_ok()); + } + + #[test] + fn create_runtime_has_op_state() { + let config = DenoConfig { + script: None, + file: None, + permissions: DenoPermissions::default(), + modules: vec![], + env: HashMap::new(), + timeout_ms: None, + }; + let runtime = + create_runtime(&config, serde_json::json!({"key": "val"}), "my-step").unwrap(); + let state = runtime.op_state(); + let state = state.borrow(); + let inputs = state.borrow::(); + assert_eq!(inputs.data, serde_json::json!({"key": "val"})); + let meta = state.borrow::(); + assert_eq!(meta.name, "my-step"); + } +} diff --git a/wfe-yaml/src/executors/deno/step.rs b/wfe-yaml/src/executors/deno/step.rs new file mode 100644 index 0000000..0300108 --- /dev/null +++ b/wfe-yaml/src/executors/deno/step.rs @@ -0,0 +1,156 @@ +use async_trait::async_trait; +use wfe_core::models::ExecutionResult; +use wfe_core::traits::step::{StepBody, StepExecutionContext}; +use wfe_core::WfeError; + +use super::config::DenoConfig; +use super::ops::workflow::StepOutputs; +use super::runtime::create_runtime; + +/// A workflow step that executes JavaScript inside a Deno runtime. +pub struct DenoStep { + config: DenoConfig, +} + +impl DenoStep { + pub fn new(config: DenoConfig) -> Self { + Self { config } + } +} + +#[async_trait] +impl StepBody for DenoStep { + async fn run( + &mut self, + context: &StepExecutionContext<'_>, + ) -> wfe_core::Result { + let step_name = context + .step + .name + .as_deref() + .unwrap_or("unknown") + .to_string(); + + let workflow_data = context.workflow.data.clone(); + + // Resolve script source. + let source = if let Some(ref script) = self.config.script { + script.clone() + } else if let Some(ref file_path) = self.config.file { + std::fs::read_to_string(file_path).map_err(|e| { + WfeError::StepExecution(format!( + "Failed to read deno script file '{}': {}", + file_path, e + )) + })? + } else { + return Err(WfeError::StepExecution( + "Deno step must have either 'script' or 'file' configured".to_string(), + )); + }; + + let config = self.config.clone(); + let timeout_ms = self.config.timeout_ms; + + // JsRuntime is !Send, so we run it on a dedicated thread with its own + // single-threaded tokio runtime. + let handle = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + WfeError::StepExecution(format!("Failed to build tokio runtime: {e}")) + })?; + + rt.block_on(async move { + run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms) + .await + }) + }); + + // Wait for the thread. + tokio::task::spawn_blocking(move || { + handle + .join() + .map_err(|_| WfeError::StepExecution("Deno thread panicked".to_string()))? + }) + .await + .map_err(|e| WfeError::StepExecution(format!("Join error: {e}")))? + } +} + +async fn run_script_inner( + config: &DenoConfig, + workflow_data: serde_json::Value, + step_name: &str, + source: &str, + timeout_ms: Option, +) -> wfe_core::Result { + let mut runtime = create_runtime(config, workflow_data, step_name)?; + + // If a timeout is configured, set up a V8 termination timer. + // This handles synchronous infinite loops that never yield to the event loop. + let _timeout_guard = timeout_ms.map(|ms| { + let isolate_handle = runtime.v8_isolate().thread_safe_handle(); + let duration = std::time::Duration::from_millis(ms); + std::thread::spawn(move || { + std::thread::sleep(duration); + isolate_handle.terminate_execution(); + }) + }); + + // Execute the script. + runtime + .execute_script("", source.to_string()) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("terminated") { + WfeError::StepExecution(format!( + "Deno script timed out after {}ms", + timeout_ms.unwrap_or(0) + )) + } else { + WfeError::StepExecution(format!("Deno script error: {e}")) + } + })?; + + // Run the event loop to completion. + runtime + .run_event_loop(Default::default()) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("terminated") { + WfeError::StepExecution(format!( + "Deno script timed out after {}ms", + timeout_ms.unwrap_or(0) + )) + } else { + WfeError::StepExecution(format!("Deno event loop error: {e}")) + } + })?; + + // Extract outputs from OpState. + let outputs = { + let state = runtime.op_state(); + let mut state = state.borrow_mut(); + let step_outputs = state.borrow_mut::(); + std::mem::take(&mut step_outputs.map) + }; + + let output_data = if outputs.is_empty() { + None + } else { + Some(serde_json::Value::Object( + outputs + .into_iter() + .collect::>(), + )) + }; + + Ok(ExecutionResult { + proceed: true, + output_data, + ..Default::default() + }) +} diff --git a/wfe-yaml/src/executors/mod.rs b/wfe-yaml/src/executors/mod.rs index 327cf1b..f3d14cf 100644 --- a/wfe-yaml/src/executors/mod.rs +++ b/wfe-yaml/src/executors/mod.rs @@ -1 +1,4 @@ pub mod shell; + +#[cfg(feature = "deno")] +pub mod deno; diff --git a/wfe-yaml/src/schema.rs b/wfe-yaml/src/schema.rs index 9e112c3..6da8a4d 100644 --- a/wfe-yaml/src/schema.rs +++ b/wfe-yaml/src/schema.rs @@ -54,6 +54,27 @@ pub struct StepConfig { pub env: HashMap, pub timeout: Option, pub working_dir: Option, + #[serde(default)] + pub permissions: Option, + #[serde(default)] + pub modules: Vec, +} + +/// YAML-level permission configuration for Deno steps. +#[derive(Debug, Deserialize, Clone, Default)] +pub struct DenoPermissionsYaml { + #[serde(default)] + pub net: Vec, + #[serde(default)] + pub read: Vec, + #[serde(default)] + pub write: Vec, + #[serde(default)] + pub env: Vec, + #[serde(default)] + pub run: bool, + #[serde(default)] + pub dynamic_import: bool, } #[derive(Debug, Deserialize)] diff --git a/wfe-yaml/src/validation.rs b/wfe-yaml/src/validation.rs index e0f1cc3..4437ff2 100644 --- a/wfe-yaml/src/validation.rs +++ b/wfe-yaml/src/validation.rs @@ -71,6 +71,24 @@ fn validate_steps( } } + // Deno steps must have config with script or file. + if let Some(ref step_type) = step.step_type + && step_type == "deno" + { + let config = step.config.as_ref().ok_or_else(|| { + YamlWorkflowError::Validation(format!( + "Deno step '{}' must have a 'config' section", + step.name + )) + })?; + if config.script.is_none() && config.file.is_none() { + return Err(YamlWorkflowError::Validation(format!( + "Deno step '{}' must have 'config.script' or 'config.file'", + step.name + ))); + } + } + // Validate step-level error behavior. if let Some(ref eb) = step.error_behavior { validate_error_behavior_type(&eb.behavior_type)?; diff --git a/wfe-yaml/tests/deno.rs b/wfe-yaml/tests/deno.rs new file mode 100644 index 0000000..454ab17 --- /dev/null +++ b/wfe-yaml/tests/deno.rs @@ -0,0 +1,269 @@ +#![cfg(feature = "deno")] + +use std::collections::HashMap; + +use wfe_yaml::executors::deno::config::{DenoConfig, DenoPermissions}; +use wfe_yaml::executors::deno::permissions::PermissionChecker; +use wfe_yaml::executors::deno::runtime::create_runtime; +use wfe_yaml::executors::deno::step::DenoStep; + +use wfe_core::models::execution_pointer::ExecutionPointer; +use wfe_core::models::workflow_definition::WorkflowStep; +use wfe_core::models::workflow_instance::WorkflowInstance; +use wfe_core::traits::step::{StepBody, StepExecutionContext}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn default_config(script: &str) -> DenoConfig { + DenoConfig { + script: Some(script.to_string()), + file: None, + permissions: DenoPermissions::default(), + modules: vec![], + env: HashMap::new(), + timeout_ms: None, + } +} + +fn make_context<'a>( + step: &'a WorkflowStep, + workflow: &'a WorkflowInstance, + pointer: &'a ExecutionPointer, +) -> StepExecutionContext<'a> { + StepExecutionContext { + item: None, + execution_pointer: pointer, + persistence_data: None, + step, + workflow, + cancellation_token: tokio_util::sync::CancellationToken::new(), + } +} + +fn make_test_fixtures( + data: serde_json::Value, +) -> (WorkflowStep, WorkflowInstance, ExecutionPointer) { + let mut step = WorkflowStep::new(0, "deno"); + step.name = Some("test-deno-step".to_string()); + let workflow = WorkflowInstance::new("test-workflow", 1, data); + let pointer = ExecutionPointer::new(0); + (step, workflow, pointer) +} + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn deno_inline_script_completes() { + let mut step = DenoStep::new(default_config("1 + 1;")); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + assert!(result.proceed); +} + +#[tokio::test] +async fn deno_output_captured() { + let mut step = DenoStep::new(default_config(r#"output("greeting", "hello");"#)); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + assert!(result.proceed); + let data = result.output_data.unwrap(); + assert_eq!(data["greeting"], serde_json::json!("hello")); +} + +#[tokio::test] +async fn deno_inputs_available() { + let script = r#" + const data = inputs(); + output("got_name", data.name); + "#; + let mut step = DenoStep::new(default_config(script)); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({"name": "alice"})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["got_name"], serde_json::json!("alice")); +} + +#[tokio::test] +async fn deno_script_error_fails_step() { + let mut step = DenoStep::new(default_config("throw new Error('boom');")); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let err = step.run(&ctx).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("boom") || msg.contains("Error"), "got: {msg}"); +} + +#[tokio::test] +async fn deno_timeout_kills_execution() { + let mut config = default_config("while (true) {}"); + config.timeout_ms = Some(100); + let mut step = DenoStep::new(config); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let err = step.run(&ctx).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("timed out"), "got: {msg}"); +} + +#[tokio::test] +async fn deno_multiple_outputs() { + let script = r#" + output("a", 1); + output("b", "two"); + output("c", true); + "#; + let mut step = DenoStep::new(default_config(script)); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + assert_eq!(data["a"], serde_json::json!(1)); + assert_eq!(data["b"], serde_json::json!("two")); + assert_eq!(data["c"], serde_json::json!(true)); +} + +#[tokio::test] +async fn deno_log_works() { + let mut step = DenoStep::new(default_config(r#"log("hello from deno");"#)); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + // Should not crash. + let result = step.run(&ctx).await.unwrap(); + assert!(result.proceed); +} + +#[tokio::test] +async fn deno_empty_script_completes() { + let mut step = DenoStep::new(default_config("")); + let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({})); + let ctx = make_context(&ws, &wf, &ptr); + let result = step.run(&ctx).await.unwrap(); + assert!(result.proceed); + assert!(result.output_data.is_none()); +} + +// --------------------------------------------------------------------------- +// Permission integration tests +// --------------------------------------------------------------------------- + +#[test] +fn permission_net_allowed() { + let perms = DenoPermissions { + net: vec!["api.example.com".to_string()], + ..Default::default() + }; + let checker = PermissionChecker::from_config(&perms); + assert!(checker.check_net("api.example.com").is_ok()); +} + +#[test] +fn permission_net_denied() { + let perms = DenoPermissions::default(); + let checker = PermissionChecker::from_config(&perms); + assert!(checker.check_net("evil.com").is_err()); +} + +#[test] +fn permission_env_denied() { + let perms = DenoPermissions { + env: vec!["SAFE_VAR".to_string()], + ..Default::default() + }; + let checker = PermissionChecker::from_config(&perms); + assert!(checker.check_env("SECRET_KEY").is_err()); + assert!(checker.check_env("SAFE_VAR").is_ok()); +} + +// --------------------------------------------------------------------------- +// Runtime creation tests +// --------------------------------------------------------------------------- + +#[test] +fn runtime_creation_succeeds() { + let config = default_config("1"); + let rt = create_runtime(&config, serde_json::json!({"x": 1}), "test"); + assert!(rt.is_ok()); +} + +#[test] +fn runtime_op_state_populated() { + let config = default_config("1"); + let rt = create_runtime(&config, serde_json::json!({"foo": "bar"}), "my-step").unwrap(); + let state = rt.op_state(); + let state = state.borrow(); + let inputs = state.borrow::(); + assert_eq!(inputs.data, serde_json::json!({"foo": "bar"})); +} + +// --------------------------------------------------------------------------- +// Validation tests +// --------------------------------------------------------------------------- + +#[test] +fn validation_deno_step_missing_config() { + let yaml = r#" +workflow: + id: test + version: 1 + steps: + - name: run_js + type: deno +"#; + let config = HashMap::new(); + let result = wfe_yaml::load_workflow_from_str(yaml, &config); + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("config") || msg.contains("Deno"), + "got: {msg}" + ); +} + +#[test] +fn validation_deno_step_missing_script_and_file() { + let yaml = r#" +workflow: + id: test + version: 1 + steps: + - name: run_js + type: deno + config: + env: + FOO: bar +"#; + let config = HashMap::new(); + let result = wfe_yaml::load_workflow_from_str(yaml, &config); + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("script") || msg.contains("file"), + "got: {msg}" + ); +} + +#[test] +fn compilation_deno_step_produces_factory() { + let yaml = r#" +workflow: + id: test-deno + version: 1 + steps: + - name: js_step + type: deno + config: + script: "output('key', 'val');" +"#; + let config = HashMap::new(); + let compiled = wfe_yaml::load_workflow_from_str(yaml, &config).unwrap(); + assert!(!compiled.step_factories.is_empty()); + let (key, _factory) = &compiled.step_factories[0]; + assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}"); +}