feat(wfe-yaml): add deno_core JS/TS executor with sandboxed permissions

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).
This commit is contained in:
2026-03-25 22:32:07 +00:00
parent ce68e4beed
commit 6fec7dbab5
15 changed files with 1127 additions and 66 deletions

View File

@@ -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<String>,
/// Path to a script file.
pub file: Option<String>,
/// Permission allowlists.
#[serde(default)]
pub permissions: DenoPermissions,
/// ES modules to pre-load.
#[serde(default)]
pub modules: Vec<String>,
/// Extra environment variables.
#[serde(default)]
pub env: HashMap<String, String>,
/// Execution timeout in milliseconds.
pub timeout_ms: Option<u64>,
}
/// 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<String>,
/// Allowed file-system read paths.
#[serde(default)]
pub read: Vec<String>,
/// Allowed file-system write paths.
#[serde(default)]
pub write: Vec<String>,
/// Allowed environment variable names.
#[serde(default)]
pub env: Vec<String>,
/// 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));
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
pub mod workflow;
pub use workflow::{StepMeta, StepOutputs, WorkflowInputs};

View File

@@ -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<String, serde_json::Value>,
}
/// 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::<WorkflowInputs>();
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::<StepOutputs>();
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::<StepMeta>().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"],
);

View File

@@ -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<String>,
read_paths: Vec<String>,
write_paths: Vec<String>,
env_vars: Vec<String>,
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());
}
}

View File

@@ -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<JsRuntime, WfeError> {
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::<WorkflowInputs>();
assert_eq!(inputs.data, serde_json::json!({"key": "val"}));
let meta = state.borrow::<StepMeta>();
assert_eq!(meta.name, "my-step");
}
}

View File

@@ -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<ExecutionResult> {
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<u64>,
) -> wfe_core::Result<ExecutionResult> {
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("<wfe>", 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::<StepOutputs>();
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::<serde_json::Map<String, serde_json::Value>>(),
))
};
Ok(ExecutionResult {
proceed: true,
output_data,
..Default::default()
})
}

View File

@@ -1 +1,4 @@
pub mod shell;
#[cfg(feature = "deno")]
pub mod deno;