From 5d6ec94c6c8f98113ceff5e01016f2aac3f73bd8 Mon Sep 17 00:00:00 2001 From: echobt Date: Tue, 17 Feb 2026 08:35:23 +0000 Subject: [PATCH] feat(wasm): add sandbox support and extended ABI for term-challenge Extend the validator node WASM executor and challenge registry to support term-challenge extended ABI with sandbox capabilities, new WASM exports (get_tasks, configure), and improved memory allocation handling. WASM Executor (wasm_executor.rs): - Fix alloc/allocate handling: try alloc(size) first (1 param), then fall back to allocate(size, align) (2 params), then raw memory offset. Extracted into reusable allocate_input() helper. - Add execute_get_tasks() to call get_tasks() export and read results. - Add execute_configure() to write config data and call configure(ptr, len). - Add execute_evaluation_with_sandbox() accepting SandboxPolicy alongside NetworkPolicy. Original execute_evaluation() delegates to it. - Register SandboxHostFunctions during WASM instantiation. Challenge Registry (registry.rs, discovery.rs): - Add sandbox_policy field to WasmModuleMetadata with builder method. - Add sandbox_policy field to DiscoveredChallenge. - Load companion .policy.json files when discovering WASM modules. - Scan challenges/ subdirectory alongside the main wasm_modules/ dir. - Extract scan_wasm_dir() helper to reduce duplication. WASM Runtime Interface (lib.rs, runtime.rs, sandbox.rs): - Add SandboxPolicy type with default, development, and term_challenge presets controlling allowed commands and execution time limits. - Add sandbox.rs module with SandboxHostFunctions implementing the platform_sandbox namespace (sandbox_exec, sandbox_get_tasks, sandbox_configure, sandbox_status host functions). - Add SandboxHostState, SandboxHostConfig, SandboxHostError types. - Add sandbox_policy to InstanceConfig and RuntimeState. - Add call_i32_return_i32() to ChallengeInstance for single-arg exports. Validator Node (main.rs): - Create challenges/ subdirectory under wasm_module_dir on startup. --- bins/validator-node/src/main.rs | 3 + bins/validator-node/src/wasm_executor.rs | 216 ++++++++++++-- crates/challenge-registry/src/discovery.rs | 85 ++++-- crates/challenge-registry/src/registry.rs | 11 +- crates/wasm-runtime-interface/src/lib.rs | 54 ++++ crates/wasm-runtime-interface/src/runtime.rs | 19 +- crates/wasm-runtime-interface/src/sandbox.rs | 294 +++++++++++++++++++ 7 files changed, 629 insertions(+), 53 deletions(-) create mode 100644 crates/wasm-runtime-interface/src/sandbox.rs diff --git a/bins/validator-node/src/main.rs b/bins/validator-node/src/main.rs index 96c9876a..e3549f12 100644 --- a/bins/validator-node/src/main.rs +++ b/bins/validator-node/src/main.rs @@ -365,6 +365,9 @@ async fn main() -> Result<()> { }; std::fs::create_dir_all(&wasm_module_dir)?; + let challenges_subdir = wasm_module_dir.join("challenges"); + std::fs::create_dir_all(&challenges_subdir)?; + let wasm_executor = match WasmChallengeExecutor::new(WasmExecutorConfig { module_dir: wasm_module_dir.clone(), max_memory_bytes: args.wasm_max_memory, diff --git a/bins/validator-node/src/wasm_executor.rs b/bins/validator-node/src/wasm_executor.rs index 4c886a11..0efaa75c 100644 --- a/bins/validator-node/src/wasm_executor.rs +++ b/bins/validator-node/src/wasm_executor.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use std::time::Instant; use tracing::{debug, info}; use wasm_runtime_interface::{ - InstanceConfig, NetworkHostFunctions, NetworkPolicy, RuntimeConfig, WasmModule, WasmRuntime, - WasmRuntimeError, + InstanceConfig, NetworkHostFunctions, NetworkPolicy, RuntimeConfig, SandboxHostFunctions, + SandboxPolicy, WasmModule, WasmRuntime, WasmRuntimeError, }; pub struct WasmExecutorConfig { @@ -72,6 +72,21 @@ impl WasmChallengeExecutor { module_path: &str, network_policy: &NetworkPolicy, input_data: &[u8], + ) -> Result<(i64, ExecutionMetrics)> { + self.execute_evaluation_with_sandbox( + module_path, + network_policy, + &SandboxPolicy::default(), + input_data, + ) + } + + pub fn execute_evaluation_with_sandbox( + &self, + module_path: &str, + network_policy: &NetworkPolicy, + sandbox_policy: &SandboxPolicy, + input_data: &[u8], ) -> Result<(i64, ExecutionMetrics)> { let start = Instant::now(); @@ -80,9 +95,11 @@ impl WasmChallengeExecutor { .context("Failed to load WASM module")?; let network_host_fns = Arc::new(NetworkHostFunctions::all()); + let _sandbox_host_fns = Arc::new(SandboxHostFunctions::all()); let instance_config = InstanceConfig { network_policy: network_policy.clone(), + sandbox_policy: sandbox_policy.clone(), audit_logger: None, memory_export: "memory".to_string(), challenge_id: module_path.to_string(), @@ -98,20 +115,7 @@ impl WasmChallengeExecutor { let initial_fuel = instance.fuel_remaining(); - let alloc_result = instance.call_i32_i32_return_i32("allocate", input_data.len() as i32, 0); - let ptr = match alloc_result { - Ok(p) => p, - Err(_) => { - let mem_size = instance.memory().data_size(instance.store()); - let offset = mem_size.saturating_sub(input_data.len() + 1024); - if offset == 0 { - return Err(anyhow::anyhow!( - "WASM module has insufficient memory for input data" - )); - } - offset as i32 - } - }; + let ptr = self.allocate_input(&mut instance, input_data)?; instance .write_memory(ptr as usize, input_data) @@ -168,9 +172,11 @@ impl WasmChallengeExecutor { .context("Failed to load WASM module")?; let network_host_fns = Arc::new(NetworkHostFunctions::all()); + let _sandbox_host_fns = Arc::new(SandboxHostFunctions::all()); let instance_config = InstanceConfig { network_policy: network_policy.clone(), + sandbox_policy: SandboxPolicy::default(), audit_logger: None, memory_export: "memory".to_string(), challenge_id: module_path.to_string(), @@ -186,20 +192,7 @@ impl WasmChallengeExecutor { let initial_fuel = instance.fuel_remaining(); - let alloc_result = instance.call_i32_i32_return_i32("allocate", input_data.len() as i32, 0); - let ptr = match alloc_result { - Ok(p) => p, - Err(_) => { - let mem_size = instance.memory().data_size(instance.store()); - let offset = mem_size.saturating_sub(input_data.len() + 1024); - if offset == 0 { - return Err(anyhow::anyhow!( - "WASM module has insufficient memory for input data" - )); - } - offset as i32 - } - }; + let ptr = self.allocate_input(&mut instance, input_data)?; instance .write_memory(ptr as usize, input_data) @@ -244,6 +237,169 @@ impl WasmChallengeExecutor { Ok((valid, metrics)) } + fn allocate_input( + &self, + instance: &mut wasm_runtime_interface::ChallengeInstance, + input_data: &[u8], + ) -> Result { + if let Ok(p) = instance.call_i32_return_i32("alloc", input_data.len() as i32) { + return Ok(p); + } + + if let Ok(p) = instance.call_i32_i32_return_i32("allocate", input_data.len() as i32, 0) { + return Ok(p); + } + + let mem_size = instance.memory().data_size(instance.store()); + let offset = mem_size.saturating_sub(input_data.len() + 1024); + if offset == 0 { + return Err(anyhow::anyhow!( + "WASM module has insufficient memory for input data" + )); + } + Ok(offset as i32) + } + + #[allow(dead_code)] + pub fn execute_get_tasks( + &self, + module_path: &str, + network_policy: &NetworkPolicy, + sandbox_policy: &SandboxPolicy, + ) -> Result<(Vec, ExecutionMetrics)> { + let start = Instant::now(); + + let module = self + .load_module(module_path) + .context("Failed to load WASM module")?; + + let network_host_fns = Arc::new(NetworkHostFunctions::all()); + + let instance_config = InstanceConfig { + network_policy: network_policy.clone(), + sandbox_policy: sandbox_policy.clone(), + audit_logger: None, + memory_export: "memory".to_string(), + challenge_id: module_path.to_string(), + validator_id: "validator".to_string(), + restart_id: String::new(), + config_version: 0, + }; + + let mut instance = self + .runtime + .instantiate(&module, instance_config, Some(network_host_fns)) + .map_err(|e| anyhow::anyhow!("WASM instantiation failed: {}", e))?; + + let initial_fuel = instance.fuel_remaining(); + + let result_ptr = instance + .call_return_i32("get_tasks") + .map_err(|e| anyhow::anyhow!("WASM get_tasks call failed: {}", e))?; + + let result_data = if result_ptr > 0 { + let len = instance + .call_return_i32("get_tasks_result_len") + .unwrap_or(0); + if len > 0 { + instance + .read_memory(result_ptr as usize, len as usize) + .unwrap_or_default() + } else { + Vec::new() + } + } else { + Vec::new() + }; + + let fuel_consumed = match (initial_fuel, instance.fuel_remaining()) { + (Some(initial), Some(remaining)) => Some(initial.saturating_sub(remaining)), + _ => None, + }; + + let metrics = ExecutionMetrics { + execution_time_ms: start.elapsed().as_millis(), + memory_used_bytes: instance.memory().data_size(instance.store()) as u64, + network_requests_made: instance.network_requests_made(), + fuel_consumed, + }; + + info!( + module = module_path, + result_bytes = result_data.len(), + execution_time_ms = metrics.execution_time_ms, + "WASM get_tasks completed" + ); + + Ok((result_data, metrics)) + } + + #[allow(dead_code)] + pub fn execute_configure( + &self, + module_path: &str, + network_policy: &NetworkPolicy, + sandbox_policy: &SandboxPolicy, + config_data: &[u8], + ) -> Result<(i32, ExecutionMetrics)> { + let start = Instant::now(); + + let module = self + .load_module(module_path) + .context("Failed to load WASM module")?; + + let network_host_fns = Arc::new(NetworkHostFunctions::all()); + + let instance_config = InstanceConfig { + network_policy: network_policy.clone(), + sandbox_policy: sandbox_policy.clone(), + audit_logger: None, + memory_export: "memory".to_string(), + challenge_id: module_path.to_string(), + validator_id: "validator".to_string(), + restart_id: String::new(), + config_version: 0, + }; + + let mut instance = self + .runtime + .instantiate(&module, instance_config, Some(network_host_fns)) + .map_err(|e| anyhow::anyhow!("WASM instantiation failed: {}", e))?; + + let initial_fuel = instance.fuel_remaining(); + + let ptr = self.allocate_input(&mut instance, config_data)?; + + instance + .write_memory(ptr as usize, config_data) + .map_err(|e| anyhow::anyhow!("Failed to write config data to WASM memory: {}", e))?; + + let result = instance + .call_i32_i32_return_i32("configure", ptr, config_data.len() as i32) + .map_err(|e| anyhow::anyhow!("WASM configure call failed: {}", e))?; + + let fuel_consumed = match (initial_fuel, instance.fuel_remaining()) { + (Some(initial), Some(remaining)) => Some(initial.saturating_sub(remaining)), + _ => None, + }; + + let metrics = ExecutionMetrics { + execution_time_ms: start.elapsed().as_millis(), + memory_used_bytes: instance.memory().data_size(instance.store()) as u64, + network_requests_made: instance.network_requests_made(), + fuel_consumed, + }; + + info!( + module = module_path, + result, + execution_time_ms = metrics.execution_time_ms, + "WASM configure completed" + ); + + Ok((result, metrics)) + } + fn load_module(&self, module_path: &str) -> Result> { { let cache = self.module_cache.read(); diff --git a/crates/challenge-registry/src/discovery.rs b/crates/challenge-registry/src/discovery.rs index 533e1d64..eef1a942 100644 --- a/crates/challenge-registry/src/discovery.rs +++ b/crates/challenge-registry/src/discovery.rs @@ -9,6 +9,7 @@ use crate::error::{RegistryError, RegistryResult}; use crate::version::ChallengeVersion; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use wasm_runtime_interface::SandboxPolicy; /// A discovered challenge that can be registered #[derive(Clone, Debug, Serialize, Deserialize)] @@ -27,6 +28,8 @@ pub struct DiscoveredChallenge { pub evaluation_endpoint: Option, /// Challenge metadata pub metadata: ChallengeMetadata, + /// Sandbox policy loaded from companion .policy.json + pub sandbox_policy: Option, /// Source of discovery pub source: DiscoverySource, } @@ -193,6 +196,7 @@ impl ChallengeDiscovery { health_endpoint: None, evaluation_endpoint: None, metadata: ChallengeMetadata::default(), + sandbox_policy: None, source: DiscoverySource::LocalFilesystem(path.clone()), }); } else if cargo_toml.exists() { @@ -211,6 +215,7 @@ impl ChallengeDiscovery { health_endpoint: None, evaluation_endpoint: None, metadata: ChallengeMetadata::default(), + sandbox_policy: None, source: DiscoverySource::LocalFilesystem(path.clone()), }); } @@ -234,32 +239,68 @@ impl ChallengeDiscovery { let mut challenges = Vec::new(); if path.is_dir() { - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.flatten() { - let entry_path = entry.path(); - if entry_path.extension().and_then(|e| e.to_str()) == Some("wasm") { - let name = entry_path - .file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - challenges.push(DiscoveredChallenge { - name, - version: ChallengeVersion::default(), - docker_image: None, - local_path: Some(entry_path.clone()), - health_endpoint: None, - evaluation_endpoint: None, - metadata: ChallengeMetadata::default(), - source: DiscoverySource::WasmDirectory(entry_path), - }); + Self::scan_wasm_dir(path, &mut challenges); + + let challenges_subdir = path.join("challenges"); + if challenges_subdir.is_dir() { + Self::scan_wasm_dir(&challenges_subdir, &mut challenges); + } + } + + Ok(challenges) + } + + fn load_sandbox_policy(wasm_path: &std::path::Path) -> Option { + let policy_path = wasm_path.with_extension("policy.json"); + if policy_path.exists() { + match std::fs::read_to_string(&policy_path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(policy) => { + tracing::info!(path = ?policy_path, "Loaded sandbox policy"); + Some(policy) } + Err(e) => { + tracing::warn!(path = ?policy_path, error = %e, "Failed to parse sandbox policy"); + None + } + }, + Err(e) => { + tracing::warn!(path = ?policy_path, error = %e, "Failed to read sandbox policy file"); + None } } + } else { + None } + } - Ok(challenges) + fn scan_wasm_dir(dir: &std::path::Path, challenges: &mut Vec) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.extension().and_then(|e| e.to_str()) == Some("wasm") { + let name = entry_path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let sandbox_policy = Self::load_sandbox_policy(&entry_path); + + challenges.push(DiscoveredChallenge { + name, + version: ChallengeVersion::default(), + docker_image: None, + local_path: Some(entry_path.clone()), + health_endpoint: None, + evaluation_endpoint: None, + metadata: ChallengeMetadata::default(), + sandbox_policy, + source: DiscoverySource::WasmDirectory(entry_path), + }); + } + } + } } /// Manually add a discovered challenge @@ -317,6 +358,7 @@ mod tests { author: Some("Platform".to_string()), ..Default::default() }, + sandbox_policy: None, source: DiscoverySource::Manual, }; @@ -338,6 +380,7 @@ mod tests { health_endpoint: None, evaluation_endpoint: None, metadata: ChallengeMetadata::default(), + sandbox_policy: None, source: DiscoverySource::Manual, }); diff --git a/crates/challenge-registry/src/registry.rs b/crates/challenge-registry/src/registry.rs index 418a4dbf..a470dc54 100644 --- a/crates/challenge-registry/src/registry.rs +++ b/crates/challenge-registry/src/registry.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info, warn}; -use wasm_runtime_interface::NetworkPolicy; +use wasm_runtime_interface::{NetworkPolicy, SandboxPolicy}; /// WASM module metadata for a challenge #[derive(Clone, Debug, Serialize, Deserialize)] @@ -25,6 +25,9 @@ pub struct WasmModuleMetadata { /// Network policy for WASM execution #[serde(default)] pub network_policy: NetworkPolicy, + /// Sandbox policy for term-challenge execution + #[serde(default)] + pub sandbox_policy: Option, /// Restartable configuration identifier #[serde(default)] pub restart_id: Option, @@ -45,11 +48,17 @@ impl WasmModuleMetadata { module_location, entrypoint, network_policy, + sandbox_policy: None, restart_id: None, config_version: 0, } } + pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self { + self.sandbox_policy = Some(policy); + self + } + /// Verify that the given module bytes match the stored hash pub fn verify_hash(&self, module_bytes: &[u8]) -> bool { use sha2::{Digest, Sha256}; diff --git a/crates/wasm-runtime-interface/src/lib.rs b/crates/wasm-runtime-interface/src/lib.rs index d9cdba9b..bcb9dc03 100644 --- a/crates/wasm-runtime-interface/src/lib.rs +++ b/crates/wasm-runtime-interface/src/lib.rs @@ -11,8 +11,13 @@ use std::str::FromStr; pub mod network; pub mod runtime; +pub mod sandbox; pub mod storage; pub use network::{NetworkHostFunctions, NetworkState, NetworkStateError}; +pub use sandbox::{ + SandboxHostFunctions, HOST_SANDBOX_CONFIGURE, HOST_SANDBOX_EXEC, HOST_SANDBOX_GET_TASKS, + HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_STATUS, +}; pub use storage::{ InMemoryStorageBackend, NoopStorageBackend, StorageAuditEntry, StorageAuditLogger, StorageBackend, StorageDeleteRequest, StorageGetRequest, StorageGetResponse, StorageHostConfig, @@ -62,6 +67,55 @@ pub struct NetworkPolicy { pub audit: AuditPolicy, } +/// Sandbox policy for term-challenge WASM modules. +/// +/// Controls whether sandbox command execution is permitted and enforces +/// resource limits on spawned processes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxPolicy { + /// Whether sandbox execution is enabled. + pub enable_sandbox: bool, + /// Commands the WASM module is allowed to invoke. + pub allowed_commands: Vec, + /// Maximum wall-clock execution time in seconds per command. + pub max_execution_time_secs: u64, +} + +impl Default for SandboxPolicy { + fn default() -> Self { + Self { + enable_sandbox: false, + allowed_commands: Vec::new(), + max_execution_time_secs: 30, + } + } +} + +impl SandboxPolicy { + /// Permissive sandbox policy for development. + pub fn development() -> Self { + Self { + enable_sandbox: true, + allowed_commands: vec!["*".to_string()], + max_execution_time_secs: 120, + } + } + + /// Term-challenge default sandbox policy. + pub fn term_challenge() -> Self { + Self { + enable_sandbox: true, + allowed_commands: vec![ + "bash".to_string(), + "sh".to_string(), + "python3".to_string(), + "node".to_string(), + ], + max_execution_time_secs: 60, + } + } +} + impl NetworkPolicy { /// Strict policy with explicit allow-list and HTTPS only. pub fn strict(allowed_hosts: Vec) -> Self { diff --git a/crates/wasm-runtime-interface/src/runtime.rs b/crates/wasm-runtime-interface/src/runtime.rs index a1578a75..02a1ab44 100644 --- a/crates/wasm-runtime-interface/src/runtime.rs +++ b/crates/wasm-runtime-interface/src/runtime.rs @@ -1,4 +1,4 @@ -use crate::{NetworkAuditLogger, NetworkPolicy, NetworkState}; +use crate::{NetworkAuditLogger, NetworkPolicy, NetworkState, SandboxPolicy}; use std::sync::Arc; use thiserror::Error; use tracing::info; @@ -75,6 +75,8 @@ impl Default for RuntimeConfig { pub struct InstanceConfig { /// Network policy enforced by host functions. pub network_policy: NetworkPolicy, + /// Sandbox policy for term-challenge execution. + pub sandbox_policy: SandboxPolicy, /// Optional audit logger for network calls. pub audit_logger: Option>, /// Wasm memory export name. @@ -93,6 +95,7 @@ impl Default for InstanceConfig { fn default() -> Self { Self { network_policy: NetworkPolicy::default(), + sandbox_policy: SandboxPolicy::default(), audit_logger: None, memory_export: DEFAULT_WASM_MEMORY_NAME.to_string(), challenge_id: "unknown".to_string(), @@ -106,6 +109,8 @@ impl Default for InstanceConfig { pub struct RuntimeState { /// Network policy available to host functions. pub network_policy: NetworkPolicy, + /// Sandbox policy for term-challenge execution. + pub sandbox_policy: SandboxPolicy, /// Mutable network state enforcing policy. pub network_state: NetworkState, /// Wasm memory export name. @@ -125,6 +130,7 @@ impl RuntimeState { #[allow(clippy::too_many_arguments)] pub fn new( network_policy: NetworkPolicy, + sandbox_policy: SandboxPolicy, network_state: NetworkState, memory_export: String, challenge_id: String, @@ -135,6 +141,7 @@ impl RuntimeState { ) -> Self { Self { network_policy, + sandbox_policy, network_state, memory_export, challenge_id, @@ -214,6 +221,7 @@ impl WasmRuntime { .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; let runtime_state = RuntimeState::new( instance_config.network_policy.clone(), + instance_config.sandbox_policy.clone(), network_state, instance_config.memory_export.clone(), instance_config.challenge_id.clone(), @@ -360,6 +368,15 @@ impl ChallengeInstance { .map_err(|err: WasmtimeError| WasmRuntimeError::Execution(err.to_string())) } + pub fn call_i32_return_i32(&mut self, name: &str, arg0: i32) -> Result { + let func = self + .instance + .get_typed_func::(&mut self.store, name) + .map_err(|_| WasmRuntimeError::MissingExport(name.to_string()))?; + func.call(&mut self.store, arg0) + .map_err(|err: WasmtimeError| WasmRuntimeError::Execution(err.to_string())) + } + pub fn call_return_i32(&mut self, name: &str) -> Result { let func = self .instance diff --git a/crates/wasm-runtime-interface/src/sandbox.rs b/crates/wasm-runtime-interface/src/sandbox.rs new file mode 100644 index 00000000..43854226 --- /dev/null +++ b/crates/wasm-runtime-interface/src/sandbox.rs @@ -0,0 +1,294 @@ +//! Sandbox Host Functions for WASM Challenges +//! +//! This module provides host functions that allow WASM code to interact with +//! sandboxed command execution. All operations are gated by `SandboxPolicy`. +//! +//! # Host Functions +//! +//! - `sandbox_exec(cmd_ptr, cmd_len) -> i64` - Execute a sandboxed command +//! - `sandbox_get_tasks() -> i64` - Retrieve pending task list +//! - `sandbox_configure(cfg_ptr, cfg_len) -> i32` - Update sandbox configuration +//! - `sandbox_status() -> i32` - Query sandbox status + +#![allow(dead_code, unused_variables, unused_imports)] + +use crate::SandboxPolicy; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use thiserror::Error; +use tracing::{debug, info, warn}; + +pub const HOST_SANDBOX_NAMESPACE: &str = "platform_sandbox"; +pub const HOST_SANDBOX_EXEC: &str = "sandbox_exec"; +pub const HOST_SANDBOX_GET_TASKS: &str = "sandbox_get_tasks"; +pub const HOST_SANDBOX_CONFIGURE: &str = "sandbox_configure"; +pub const HOST_SANDBOX_STATUS: &str = "sandbox_status"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum SandboxHostStatus { + Success = 0, + Disabled = 1, + CommandNotAllowed = -1, + ExecutionTimeout = -2, + ExecutionFailed = -3, + InvalidConfig = -4, + InternalError = -100, +} + +impl SandboxHostStatus { + pub fn to_i32(self) -> i32 { + self as i32 + } + + pub fn from_i32(code: i32) -> Self { + match code { + 0 => Self::Success, + 1 => Self::Disabled, + -1 => Self::CommandNotAllowed, + -2 => Self::ExecutionTimeout, + -3 => Self::ExecutionFailed, + -4 => Self::InvalidConfig, + _ => Self::InternalError, + } + } +} + +#[derive(Debug, Error)] +pub enum SandboxHostError { + #[error("sandbox disabled")] + Disabled, + + #[error("command not allowed: {0}")] + CommandNotAllowed(String), + + #[error("execution timeout after {0}s")] + ExecutionTimeout(u64), + + #[error("execution failed: {0}")] + ExecutionFailed(String), + + #[error("invalid configuration: {0}")] + InvalidConfig(String), + + #[error("memory error: {0}")] + MemoryError(String), + + #[error("internal error: {0}")] + InternalError(String), +} + +impl From for SandboxHostStatus { + fn from(err: SandboxHostError) -> Self { + match err { + SandboxHostError::Disabled => Self::Disabled, + SandboxHostError::CommandNotAllowed(_) => Self::CommandNotAllowed, + SandboxHostError::ExecutionTimeout(_) => Self::ExecutionTimeout, + SandboxHostError::ExecutionFailed(_) => Self::ExecutionFailed, + SandboxHostError::InvalidConfig(_) => Self::InvalidConfig, + SandboxHostError::MemoryError(_) => Self::InternalError, + SandboxHostError::InternalError(_) => Self::InternalError, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxHostConfig { + pub policy: SandboxPolicy, + pub max_concurrent_tasks: usize, + pub max_output_bytes: usize, +} + +impl Default for SandboxHostConfig { + fn default() -> Self { + Self { + policy: SandboxPolicy::default(), + max_concurrent_tasks: 4, + max_output_bytes: 1024 * 1024, + } + } +} + +impl SandboxHostConfig { + pub fn permissive() -> Self { + Self { + policy: SandboxPolicy::development(), + max_concurrent_tasks: 16, + max_output_bytes: 10 * 1024 * 1024, + } + } + + pub fn is_command_allowed(&self, command: &str) -> bool { + if !self.policy.enable_sandbox { + return false; + } + self.policy + .allowed_commands + .iter() + .any(|c| c == "*" || c == command) + } +} + +pub struct SandboxHostState { + pub config: SandboxHostConfig, + pub challenge_id: String, + pub pending_results: HashMap>, + pub next_result_id: u32, + pub commands_executed: u32, +} + +impl SandboxHostState { + pub fn new(challenge_id: String, config: SandboxHostConfig) -> Self { + Self { + config, + challenge_id, + pending_results: HashMap::new(), + next_result_id: 1, + commands_executed: 0, + } + } + + pub fn store_result(&mut self, data: Vec) -> u32 { + let id = self.next_result_id; + self.next_result_id = self.next_result_id.wrapping_add(1); + self.pending_results.insert(id, data); + id + } + + pub fn take_result(&mut self, id: u32) -> Option> { + self.pending_results.remove(&id) + } + + pub fn reset_counters(&mut self) { + self.commands_executed = 0; + } +} + +pub struct SandboxHostFunctions; + +impl SandboxHostFunctions { + pub fn all() -> Self { + Self + } +} + +impl crate::runtime::HostFunctionRegistrar for SandboxHostFunctions { + fn register( + &self, + linker: &mut wasmtime::Linker, + ) -> Result<(), crate::runtime::WasmRuntimeError> { + linker + .func_wrap(HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_STATUS, || -> i32 { + SandboxHostStatus::Success.to_i32() + }) + .map_err(|e| { + crate::runtime::WasmRuntimeError::HostFunction(format!( + "failed to register {}: {}", + HOST_SANDBOX_STATUS, e + )) + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sandbox_host_status_conversion() { + assert_eq!(SandboxHostStatus::Success.to_i32(), 0); + assert_eq!(SandboxHostStatus::Disabled.to_i32(), 1); + assert_eq!(SandboxHostStatus::CommandNotAllowed.to_i32(), -1); + assert_eq!(SandboxHostStatus::InternalError.to_i32(), -100); + + assert_eq!(SandboxHostStatus::from_i32(0), SandboxHostStatus::Success); + assert_eq!(SandboxHostStatus::from_i32(1), SandboxHostStatus::Disabled); + assert_eq!( + SandboxHostStatus::from_i32(-1), + SandboxHostStatus::CommandNotAllowed + ); + assert_eq!( + SandboxHostStatus::from_i32(-999), + SandboxHostStatus::InternalError + ); + } + + #[test] + fn test_sandbox_host_error_to_status() { + let err = SandboxHostError::Disabled; + assert_eq!(SandboxHostStatus::from(err), SandboxHostStatus::Disabled); + + let err = SandboxHostError::CommandNotAllowed("bash".to_string()); + assert_eq!( + SandboxHostStatus::from(err), + SandboxHostStatus::CommandNotAllowed + ); + + let err = SandboxHostError::ExecutionTimeout(30); + assert_eq!( + SandboxHostStatus::from(err), + SandboxHostStatus::ExecutionTimeout + ); + } + + #[test] + fn test_sandbox_host_config_command_check() { + let config = SandboxHostConfig::default(); + assert!(!config.is_command_allowed("bash")); + + let config = SandboxHostConfig::permissive(); + assert!(config.is_command_allowed("bash")); + assert!(config.is_command_allowed("anything")); + + let config = SandboxHostConfig { + policy: SandboxPolicy { + enable_sandbox: true, + allowed_commands: vec!["bash".to_string(), "sh".to_string()], + max_execution_time_secs: 30, + }, + ..Default::default() + }; + assert!(config.is_command_allowed("bash")); + assert!(config.is_command_allowed("sh")); + assert!(!config.is_command_allowed("python3")); + } + + #[test] + fn test_sandbox_host_state() { + let mut state = + SandboxHostState::new("challenge-1".to_string(), SandboxHostConfig::default()); + + let id1 = state.store_result(b"result1".to_vec()); + let id2 = state.store_result(b"result2".to_vec()); + + assert_ne!(id1, id2); + + let result1 = state.take_result(id1); + assert_eq!(result1, Some(b"result1".to_vec())); + + let result1_again = state.take_result(id1); + assert_eq!(result1_again, None); + + let result2 = state.take_result(id2); + assert_eq!(result2, Some(b"result2".to_vec())); + } + + #[test] + fn test_sandbox_policy_defaults() { + let policy = SandboxPolicy::default(); + assert!(!policy.enable_sandbox); + assert!(policy.allowed_commands.is_empty()); + assert_eq!(policy.max_execution_time_secs, 30); + } + + #[test] + fn test_sandbox_policy_term_challenge() { + let policy = SandboxPolicy::term_challenge(); + assert!(policy.enable_sandbox); + assert!(policy.allowed_commands.contains(&"bash".to_string())); + assert!(policy.allowed_commands.contains(&"python3".to_string())); + assert_eq!(policy.max_execution_time_secs, 60); + } +}