Files
wfe/wfe-yaml/src/executors/deno/config.rs
Sienna Meridian Satterwhite 6fec7dbab5 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).
2026-03-25 22:32:07 +00:00

122 lines
4.3 KiB
Rust

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