Skip to content

feat(wasm-runtime): add sandbox exec, logging, and timestamp host functions#33

Open
echobt wants to merge 2 commits intomainfrom
feat/wasm-sandbox-host-functions
Open

feat(wasm-runtime): add sandbox exec, logging, and timestamp host functions#33
echobt wants to merge 2 commits intomainfrom
feat/wasm-sandbox-host-functions

Conversation

@echobt
Copy link
Contributor

@echobt echobt commented Feb 17, 2026

Summary

Adds a new platform_sandbox host function namespace to the WASM runtime interface, enabling WASM modules to execute sandboxed terminal commands, retrieve timestamps, and emit structured log messages to the host.

Changes

New: sandbox.rs module

  • sandbox_exec host function: deserializes a SandboxExecRequest from WASM memory, executes the command via std::process::Command with timeout enforcement (wait-timeout crate), captures stdout/stderr/exit_code/duration, serializes a SandboxExecResponse back into WASM memory
  • get_timestamp host function: returns current Unix timestamp in milliseconds, supports RealTime and Deterministic modes for reproducible evaluations
  • log_message host function: reads a log message from WASM memory and forwards it to the host tracing infrastructure with level mapping (Trace/Debug/Info/Warn/Error)
  • SandboxPolicy struct: security constraints including command allowlist/blocklist, max execution time, max output size, network access control, working directory, and environment variable whitelist
  • SandboxState: runtime state tracking execution count and policy enforcement
  • SandboxHostFunctions: implements HostFunctionRegistrar pattern consistent with existing NetworkHostFunctions
  • Comprehensive unit tests for policy enforcement, command filtering, timestamp modes, and execution behavior

Modified: runtime.rs

  • Extended InstanceConfig with sandbox_policy and timestamp_mode fields
  • Extended RuntimeState with SandboxState and counter reset support
  • Added instantiate_with_registrars method accepting multiple HostFunctionRegistrar instances, refactored instantiate to delegate to it
  • Linker now iterates over all registrars instead of a single optional one

Modified: lib.rs

  • Exported sandbox module and all public sandbox types

Modified: wasm_executor.rs

  • Updated InstanceConfig construction to use ..Default::default() for forward compatibility with new fields

Modified: Cargo.toml

  • Added wait-timeout dependency for subprocess timeout handling

Summary by CodeRabbit

Release Notes

  • New Features

    • Added sandbox runtime for secure WASM execution with configurable policies
    • Command allowance/blocking with timeout enforcement and output size limits
    • Environment variable whitelisting support
    • Timestamp mode configuration (real-time or deterministic)
    • Logging with multiple verbosity levels
  • Refactor

    • Enhanced host function registrar support for multiple registrars

…ing host functions

Implement host-side sandbox capabilities for WASM challenges via a new
`platform_sandbox` import module with three host functions:

- `sandbox_exec`: Deserializes SandboxExecRequest from WASM memory, executes
  commands via std::process::Command with policy enforcement (allowed/blocked
  commands, timeout via wait-timeout, output size limits, environment
  whitelisting, working directory sandboxing), and writes serialized
  SandboxExecResponse back to WASM memory.
- `get_timestamp`: Returns Unix timestamp in milliseconds, supporting both
  real-time and deterministic modes for reproducible evaluation.
- `log_message`: Reads log messages from WASM memory and forwards them to
  the host tracing infrastructure with proper level mapping (Trace through
  Error).

New SandboxPolicy struct mirrors the existing NetworkPolicy pattern with
fields for allowed/blocked commands, max execution time, max output bytes,
network access control, working directory, and environment whitelisting.

SandboxHostFunctions implements the HostFunctionRegistrar trait, consistent
with the existing NetworkHostFunctions pattern. The runtime now supports
multiple registrars via instantiate_with_registrars(), and InstanceConfig
is extended with sandbox_policy and timestamp_mode fields (using
..Default::default() in existing call sites for backward compatibility).

Files changed:
- crates/wasm-runtime-interface/src/sandbox.rs (new): Full sandbox module
  with SandboxPolicy, SandboxState, SandboxHostFunctions, request/response
  types, memory read/write helpers, and comprehensive unit tests
- crates/wasm-runtime-interface/src/runtime.rs: Added SandboxState to
  RuntimeState, sandbox_policy/timestamp_mode to InstanceConfig, and
  instantiate_with_registrars() supporting multiple registrars
- crates/wasm-runtime-interface/src/lib.rs: Export sandbox module and types
- crates/wasm-runtime-interface/Cargo.toml: Added wait-timeout dependency
- bins/validator-node/src/wasm_executor.rs: Use ..Default::default() for
  new InstanceConfig fields
- Cargo.lock: Updated with wait-timeout 0.2.1
@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

Warning

Rate limit exceeded

@echobt has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 22 minutes and 40 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Introduces in-process sandbox execution support to the WASM runtime interface. Adds a new sandbox module with request/response handling, policy enforcement, and timestamp modes. Extends InstanceConfig and RuntimeState to carry sandbox configuration. Refactors registrar handling to support multiple registrars. Adds wait-timeout dependency.

Changes

Cohort / File(s) Summary
Sandbox Module Foundation
crates/wasm-runtime-interface/src/sandbox.rs
Comprehensive new module (750+ lines) implementing sandbox execution with SandboxExecRequest/Response, SandboxPolicy with allow/block logic, SandboxState for tracking execution context, TimestampMode and LogLevel enums, SandboxError variants, command validation with timeout and output size enforcement, host function integrations (sandbox_exec, get_timestamp, log_message), and memory I/O helpers.
Runtime Integration
crates/wasm-runtime-interface/src/runtime.rs
Extended InstanceConfig with sandbox_policy and timestamp_mode fields. Extended RuntimeState with sandbox_state field and reset_sandbox_counters method. Refactored instantiate to support multiple registrars via new instantiate_with_registrars function. Updated constructors and instantiation flow to create and wire SandboxState early.
Public API Exposure
crates/wasm-runtime-interface/src/lib.rs
Added sandbox module and public re-exports of SandboxExecRequest, SandboxExecResponse, SandboxPolicy, SandboxState, SandboxError, TimestampMode, LogLevel, and host function identifiers.
Configuration & Dependencies
crates/wasm-runtime-interface/Cargo.toml, bins/validator-node/src/wasm_executor.rs
Added wait-timeout dependency (v0.2). Applied sandbox configuration defaults in InstanceConfig initialization for evaluation and validation code paths.

Sequence Diagram

sequenceDiagram
    participant WASM as WASM Module
    participant Host as Host Runtime
    participant Sandbox as SandboxState
    participant Exec as Command Executor
    participant Sys as System

    WASM->>Host: call sandbox_exec(request_ptr)
    Host->>Host: deserialize SandboxExecRequest from memory
    Host->>Sandbox: is_command_allowed(command)
    Sandbox->>Sandbox: check policy allow/block lists
    Sandbox-->>Host: Result<(), SandboxError>
    alt Command Blocked
        Host-->>WASM: error response
    else Command Allowed
        Host->>Exec: spawn Command with env, stdin, working_dir
        Exec->>Sys: execute subprocess
        Sys-->>Exec: exit code, stdout, stderr
        Exec->>Exec: enforce output_size limit
        Exec->>Sandbox: increment executions_count
        Exec-->>Host: SandboxExecResponse
        Host->>Host: serialize response to memory
        Host-->>WASM: success with response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 In the sandbox we play, where commands must obey,
Policy checks guard the way, execution logs tell the day,
With timestamps and timeouts to keep order abound,
The WASM runtime's fortress—now safely bound! 🔒

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(wasm-runtime): add sandbox exec, logging, and timestamp host functions' accurately describes the main changes—adding three new host functions (sandbox_exec, logging, timestamp) to the WASM runtime sandbox module.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/wasm-sandbox-host-functions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…t-functions

# Conflicts:
#	crates/wasm-runtime-interface/Cargo.toml
#	crates/wasm-runtime-interface/src/runtime.rs
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/wasm-runtime-interface/src/lib.rs (1)

1-5: ⚠️ Potential issue | 🟡 Minor

Module doc comment is stale — now covers more than network access.

The crate-level doc says "host interface definitions for network access" but this PR adds sandbox functionality (exec, timestamps, logging). Consider updating the doc to reflect the broader scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/lib.rs` around lines 1 - 5, Update the
crate-level doc comment in lib.rs to reflect the broader host interface (not
just network access): change the opening lines that currently state "WASM
runtime host interface definitions for network access" to a concise summary that
includes sandboxing features such as process execution, timestamps, and logging
in addition to network access; reference the crate as providing the declarative
host function surface exposed to challenge WASM modules (the same intent as
before) and mention the new sandboxing/runtime controls (exec, timestamps,
logging) so readers of the crate documentation understand its full scope.
🧹 Nitpick comments (4)
crates/wasm-runtime-interface/src/sandbox.rs (3)

225-229: Silently ignoring stdin write errors may hide failures.

Line 228 discards the result of write_all. If the write fails (e.g., broken pipe, buffer exhaustion), the command runs with partial or no input, which could produce confusing results. Consider at least logging a warning.

Log stdin write failures
         if let Some(stdin_data) = &request.stdin {
             if let Some(mut stdin) = child.stdin.take() {
                 use std::io::Write;
-                let _ = stdin.write_all(stdin_data);
+                if let Err(e) = stdin.write_all(stdin_data) {
+                    warn!(error = %e, "failed to write stdin data to child process");
+                }
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/sandbox.rs` around lines 225 - 229, The
stdin write result is currently discarded (request.stdin + child.stdin.take() +
write_all), which can hide failures; update the block that writes to child.stdin
so it checks the Result from write_all and at minimum logs a warning with the
error details (e.g., using log::warn! or tracing::warn!) including contextual
info (request id/command or similar) — alternatively propagate the error from
the enclosing function (return Err) if that better fits error handling in
sandbox.rs; ensure you reference the same request.stdin and child.stdin.take()
code path when making this change.

247-262: Output is fully buffered in memory before truncation — risk of OOM for large outputs.

read_to_end on lines 249 and 254 reads the entire output into memory, then truncates afterward (lines 257–262). A command producing gigabytes of output would consume that much host memory before truncation kicks in.

Consider using Read::take(max_output_bytes as u64) to cap the read at the policy limit:

Bounded read
         let mut stdout = Vec::new();
         if let Some(mut out) = child.stdout.take() {
-            let _ = out.read_to_end(&mut stdout);
+            let _ = out.take(self.policy.max_output_bytes as u64).read_to_end(&mut stdout);
         }
 
         let mut stderr = Vec::new();
         if let Some(mut err) = child.stderr.take() {
-            let _ = err.read_to_end(&mut stderr);
+            let _ = err.take(self.policy.max_output_bytes as u64).read_to_end(&mut stderr);
         }
-
-        if stdout.len() > self.policy.max_output_bytes {
-            stdout.truncate(self.policy.max_output_bytes);
-        }
-        if stderr.len() > self.policy.max_output_bytes {
-            stderr.truncate(self.policy.max_output_bytes);
-        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/sandbox.rs` around lines 247 - 262, The
code currently calls read_to_end on child.stdout and child.stderr (variables
out/err) which can OOM before you later truncate; change the reads to bounded
reads using Read::take(self.policy.max_output_bytes as u64) (or out.take(...) /
err.take(...)) and read from those into stdout/stderr (or use io::copy from the
take() wrapper) so the OS never buffers more than self.policy.max_output_bytes;
remove the post-read truncation since the take() already enforces the limit.

556-749: Tests provide good baseline coverage.

Policy enforcement, log level mapping, timestamp modes, basic exec, output truncation, and counter reset are all covered. Note that test_sandbox_exec_simple_command and test_sandbox_exec_output_truncation depend on the echo binary being available, making them platform-dependent (will fail on some CI environments or Windows).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/sandbox.rs` around lines 556 - 749, Tests
test_sandbox_exec_simple_command and test_sandbox_exec_output_truncation rely on
the external "echo" binary and are platform-dependent; annotate or gate these
tests so they don't run on platforms without that binary (e.g., Windows). Modify
the two tests (test_sandbox_exec_simple_command and
test_sandbox_exec_output_truncation) to use a conditional compilation attribute
such as #[cfg(unix)] or #[cfg(not(target_os = "windows"))] so they only run on
Unix-like CI, or alternatively replace them with a platform-independent approach
(e.g., invoking a built-in test helper or injecting stdout via
SandboxState::handle_exec mocks) to avoid calling external binaries directly.
crates/wasm-runtime-interface/src/runtime.rs (1)

133-165: RuntimeState constructor is getting large — consider a builder or config struct.

With 9 parameters and the #[allow(clippy::too_many_arguments)] suppression, RuntimeState::new is approaching the point where a builder pattern or passing InstanceConfig directly (plus computed state objects) would improve ergonomics. Not blocking, but something to keep in mind as more features are added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/runtime.rs` around lines 133 - 165,
RuntimeState::new currently takes nine arguments (network_policy, network_state,
sandbox_state, memory_export, challenge_id, validator_id, restart_id,
config_version, limits) and is flagged with
#[allow(clippy::too_many_arguments)]; refactor by introducing a config struct
(e.g., InstanceConfig or RuntimeConfig) that groups related parameters
(network_policy, memory_export, challenge_id, validator_id, restart_id,
config_version, limits) and have RuntimeState::new accept that config plus the
already-composed state objects (NetworkState, SandboxState), or implement a
RuntimeStateBuilder that exposes clear setters for each field and a build() that
constructs RuntimeState; update all call sites to construct the config/builder
instead of passing nine positional args and remove the clippy allow attribute
once refactored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/wasm-runtime-interface/src/sandbox.rs`:
- Around line 61-72: Default SandboxPolicy currently treats an empty
allowed_commands as "allow all", so change the default to a secure-by-default
posture by adding a new bool field (e.g., sandbox_enabled) to SandboxPolicy and
set it to false in the Default impl, or alternatively populate allowed_commands
with an explicit safe whitelist; then update is_command_allowed to first check
sandbox_enabled (deny all when false) or to treat an empty allowed_commands as
deny-all instead of allow-all; ensure the permissive() constructor still
explicitly sets sandbox_enabled = true (or populates allowed_commands to allow
all) so tests/dev keep the wide-open behavior.
- Around line 50-58: handle_exec currently ignores SandboxPolicy::allow_network
so a policy with allow_network=false doesn't prevent child processes from making
network calls; update handle_exec to respect this flag by early-returning an
error (or calling todo!/unimplemented! with a clear message) when allow_network
is false, or wire in the intended OS-level enforcement path (e.g.,
seccomp/namespace setup) before spawning the child; reference
SandboxPolicy::allow_network, the handle_exec function, and the permissive()
constructor so reviewers can locate the policy and decide whether to implement
enforcement or explicitly block/guard execution.
- Around line 232-262: The code currently calls child.wait_timeout(timeout)
before draining the child's stdout/stderr, which can deadlock if the child
blocks writing to full OS pipes; fix by reading stdout and stderr concurrently
while waiting: take child.stdout and child.stderr (as in the existing references
to child.stdout.take() / child.stderr.take()), spawn reader threads (or async
tasks) that stream each pipe into buffers and enforce the same
self.policy.max_output_bytes limit/truncation, then call
child.wait_timeout(timeout) and join the reader threads to collect outputs; on
errors, propagate SandboxError::ExecutionFailed with the reader error text and
preserve returning SandboxError::Timeout(self.policy.max_execution_time_ms) if
the wait times out, ensuring child.kill() is still called when needed.

---

Outside diff comments:
In `@crates/wasm-runtime-interface/src/lib.rs`:
- Around line 1-5: Update the crate-level doc comment in lib.rs to reflect the
broader host interface (not just network access): change the opening lines that
currently state "WASM runtime host interface definitions for network access" to
a concise summary that includes sandboxing features such as process execution,
timestamps, and logging in addition to network access; reference the crate as
providing the declarative host function surface exposed to challenge WASM
modules (the same intent as before) and mention the new sandboxing/runtime
controls (exec, timestamps, logging) so readers of the crate documentation
understand its full scope.

---

Nitpick comments:
In `@crates/wasm-runtime-interface/src/runtime.rs`:
- Around line 133-165: RuntimeState::new currently takes nine arguments
(network_policy, network_state, sandbox_state, memory_export, challenge_id,
validator_id, restart_id, config_version, limits) and is flagged with
#[allow(clippy::too_many_arguments)]; refactor by introducing a config struct
(e.g., InstanceConfig or RuntimeConfig) that groups related parameters
(network_policy, memory_export, challenge_id, validator_id, restart_id,
config_version, limits) and have RuntimeState::new accept that config plus the
already-composed state objects (NetworkState, SandboxState), or implement a
RuntimeStateBuilder that exposes clear setters for each field and a build() that
constructs RuntimeState; update all call sites to construct the config/builder
instead of passing nine positional args and remove the clippy allow attribute
once refactored.

In `@crates/wasm-runtime-interface/src/sandbox.rs`:
- Around line 225-229: The stdin write result is currently discarded
(request.stdin + child.stdin.take() + write_all), which can hide failures;
update the block that writes to child.stdin so it checks the Result from
write_all and at minimum logs a warning with the error details (e.g., using
log::warn! or tracing::warn!) including contextual info (request id/command or
similar) — alternatively propagate the error from the enclosing function (return
Err) if that better fits error handling in sandbox.rs; ensure you reference the
same request.stdin and child.stdin.take() code path when making this change.
- Around line 247-262: The code currently calls read_to_end on child.stdout and
child.stderr (variables out/err) which can OOM before you later truncate; change
the reads to bounded reads using Read::take(self.policy.max_output_bytes as u64)
(or out.take(...) / err.take(...)) and read from those into stdout/stderr (or
use io::copy from the take() wrapper) so the OS never buffers more than
self.policy.max_output_bytes; remove the post-read truncation since the take()
already enforces the limit.
- Around line 556-749: Tests test_sandbox_exec_simple_command and
test_sandbox_exec_output_truncation rely on the external "echo" binary and are
platform-dependent; annotate or gate these tests so they don't run on platforms
without that binary (e.g., Windows). Modify the two tests
(test_sandbox_exec_simple_command and test_sandbox_exec_output_truncation) to
use a conditional compilation attribute such as #[cfg(unix)] or
#[cfg(not(target_os = "windows"))] so they only run on Unix-like CI, or
alternatively replace them with a platform-independent approach (e.g., invoking
a built-in test helper or injecting stdout via SandboxState::handle_exec mocks)
to avoid calling external binaries directly.

Comment on lines +50 to +58
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxPolicy {
pub allowed_commands: Vec<String>,
pub blocked_commands: Vec<String>,
pub max_execution_time_ms: u64,
pub max_output_bytes: usize,
pub allow_network: bool,
pub working_directory: PathBuf,
pub environment_whitelist: Vec<String>,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that allow_network is never read/checked outside of tests and struct construction
rg -n 'allow_network' --type rust -C2

Repository: PlatformNetwork/platform-v2

Length of output: 1894


🏁 Script executed:

#!/bin/bash
# Find the handle_exec function to see if it checks allow_network
rg -n 'handle_exec|fn.*exec' --type rust -A5 | head -100

Repository: PlatformNetwork/platform-v2

Length of output: 7428


🏁 Script executed:

#!/bin/bash
# Check the full SandboxPolicy implementation including constructors and methods
rg -n 'impl SandboxPolicy|fn.*permissive|fn.*new' --type rust -A10

Repository: PlatformNetwork/platform-v2

Length of output: 50384


🏁 Script executed:

#!/bin/bash
# Get the full handle_exec function implementation
sed -n '185,250p' crates/wasm-runtime-interface/src/sandbox.rs

Repository: PlatformNetwork/platform-v2

Length of output: 2244


🏁 Script executed:

#!/bin/bash
# Search for any conditional check on allow_network (any pattern like if/match with allow_network)
rg 'allow_network' --type rust -B2 -A2 crates/wasm-runtime-interface/src/sandbox.rs

Repository: PlatformNetwork/platform-v2

Length of output: 929


allow_network field is declared but never enforced in handle_exec.

SandboxPolicy::allow_network (line 56) is set to false by default and in permissive(), but handle_exec never checks this flag before spawning the child process. A spawned command can freely make network calls regardless of this setting.

If network restriction is intended, it requires OS-level enforcement (e.g., seccomp, namespaces) or at minimum a pre-exec check. If this is placeholder for future work, add a todo!() / unimplemented!() guard or clearly document the intended behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/sandbox.rs` around lines 50 - 58,
handle_exec currently ignores SandboxPolicy::allow_network so a policy with
allow_network=false doesn't prevent child processes from making network calls;
update handle_exec to respect this flag by early-returning an error (or calling
todo!/unimplemented! with a clear message) when allow_network is false, or wire
in the intended OS-level enforcement path (e.g., seccomp/namespace setup) before
spawning the child; reference SandboxPolicy::allow_network, the handle_exec
function, and the permissive() constructor so reviewers can locate the policy
and decide whether to implement enforcement or explicitly block/guard execution.

Comment on lines +61 to +72
impl Default for SandboxPolicy {
fn default() -> Self {
Self {
allowed_commands: Vec::new(),
blocked_commands: Vec::new(),
max_execution_time_ms: 30_000,
max_output_bytes: 1024 * 1024,
allow_network: false,
working_directory: PathBuf::from("/tmp"),
environment_whitelist: Vec::new(),
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Default SandboxPolicy allows execution of any command — consider a secure-by-default posture.

When allowed_commands is empty (the default), is_command_allowed permits every command (line 106 only checks the allowlist when it's non-empty). Combined with an empty blocked_commands, the default policy lets a WASM module run arbitrary OS processes on the host.

This is a security-sensitive default for a sandbox boundary. Consider either:

  1. Requiring an explicit opt-in (e.g., a sandbox_enabled: bool gate that defaults to false, blocking all exec unless enabled), or
  2. Defaulting allowed_commands to a safe whitelist rather than treating empty as "allow all".

The permissive() constructor can remain wide-open for development/test use.

Also applies to: 94-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/sandbox.rs` around lines 61 - 72, Default
SandboxPolicy currently treats an empty allowed_commands as "allow all", so
change the default to a secure-by-default posture by adding a new bool field
(e.g., sandbox_enabled) to SandboxPolicy and set it to false in the Default
impl, or alternatively populate allowed_commands with an explicit safe
whitelist; then update is_command_allowed to first check sandbox_enabled (deny
all when false) or to treat an empty allowed_commands as deny-all instead of
allow-all; ensure the permissive() constructor still explicitly sets
sandbox_enabled = true (or populates allowed_commands to allow all) so tests/dev
keep the wide-open behavior.

Comment on lines +232 to +262
let exit_status = match child.wait_timeout(timeout) {
Ok(Some(status)) => status,
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(SandboxError::Timeout(self.policy.max_execution_time_ms));
}
Err(e) => {
let _ = child.kill();
return Err(SandboxError::ExecutionFailed(e.to_string()));
}
};

let duration = start.elapsed();

let mut stdout = Vec::new();
if let Some(mut out) = child.stdout.take() {
let _ = out.read_to_end(&mut stdout);
}

let mut stderr = Vec::new();
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_end(&mut stderr);
}

if stdout.len() > self.policy.max_output_bytes {
stdout.truncate(self.policy.max_output_bytes);
}
if stderr.len() > self.policy.max_output_bytes {
stderr.truncate(self.policy.max_output_bytes);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Pipe deadlock: reading stdout/stderr after wait_timeout can hang or spuriously time out.

The code calls child.wait_timeout(timeout) (line 232) before draining stdout/stderr (lines 247–255). If the child writes more than the OS pipe buffer (~64 KB on Linux), it will block on the write syscall and never exit — causing wait_timeout to hit the deadline even though the command isn't slow, it's just blocked on I/O.

The standard fix is to drain stdout/stderr concurrently with (or before) waiting. For example, spawn threads to read the pipes, or use std::process::Command output-capture via .output() combined with a timeout wrapper, or read in a loop alongside the wait.

Sketch of a fix using a dedicated reader thread
         let mut child = cmd
             .spawn()
             .map_err(|e| SandboxError::ExecutionFailed(e.to_string()))?;
 
         if let Some(stdin_data) = &request.stdin {
             if let Some(mut stdin) = child.stdin.take() {
                 use std::io::Write;
                 let _ = stdin.write_all(stdin_data);
             }
         }
 
-        let exit_status = match child.wait_timeout(timeout) {
+        // Drain stdout/stderr on background threads to prevent pipe-buffer deadlocks.
+        let stdout_handle = child.stdout.take().map(|mut out| {
+            std::thread::spawn(move || {
+                let mut buf = Vec::new();
+                let _ = out.read_to_end(&mut buf);
+                buf
+            })
+        });
+        let stderr_handle = child.stderr.take().map(|mut err| {
+            std::thread::spawn(move || {
+                let mut buf = Vec::new();
+                let _ = err.read_to_end(&mut buf);
+                buf
+            })
+        });
+
+        let exit_status = match child.wait_timeout(timeout) {
             Ok(Some(status)) => status,
             Ok(None) => {
                 let _ = child.kill();
                 let _ = child.wait();
                 return Err(SandboxError::Timeout(self.policy.max_execution_time_ms));
             }
             Err(e) => {
                 let _ = child.kill();
                 return Err(SandboxError::ExecutionFailed(e.to_string()));
             }
         };
 
         let duration = start.elapsed();
 
-        let mut stdout = Vec::new();
-        if let Some(mut out) = child.stdout.take() {
-            let _ = out.read_to_end(&mut stdout);
-        }
-
-        let mut stderr = Vec::new();
-        if let Some(mut err) = child.stderr.take() {
-            let _ = err.read_to_end(&mut stderr);
-        }
+        let mut stdout = stdout_handle
+            .and_then(|h| h.join().ok())
+            .unwrap_or_default();
+        let mut stderr = stderr_handle
+            .and_then(|h| h.join().ok())
+            .unwrap_or_default();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/wasm-runtime-interface/src/sandbox.rs` around lines 232 - 262, The
code currently calls child.wait_timeout(timeout) before draining the child's
stdout/stderr, which can deadlock if the child blocks writing to full OS pipes;
fix by reading stdout and stderr concurrently while waiting: take child.stdout
and child.stderr (as in the existing references to child.stdout.take() /
child.stderr.take()), spawn reader threads (or async tasks) that stream each
pipe into buffers and enforce the same self.policy.max_output_bytes
limit/truncation, then call child.wait_timeout(timeout) and join the reader
threads to collect outputs; on errors, propagate SandboxError::ExecutionFailed
with the reader error text and preserve returning
SandboxError::Timeout(self.policy.max_execution_time_ms) if the wait times out,
ensuring child.kill() is still called when needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant