forge-sandbox 0.6.0

V8 sandbox for executing LLM-generated JavaScript via deno_core
Documentation
#![warn(missing_docs)]

//! # forge-sandbox
//!
//! V8 sandbox for the Forgemax Code Mode Gateway.
//!
//! Executes LLM-generated JavaScript in a deno_core isolate with no filesystem,
//! network, or environment access. The only bridge to the host is through
//! explicitly registered ops that dispatch to a [`ToolDispatcher`].
//!
//! ## Security model
//!
//! - **V8 isolate**: Same process-level isolation as Chrome tabs
//! - **No ambient capabilities**: No fs, net, env, or child_process access
//! - **Fresh runtime per call**: No state leakage between executions
//! - **Pre-execution validation**: Banned patterns caught before reaching V8
//! - **Timeout enforcement**: Execution killed after configurable deadline
//! - **Output size limits**: Prevents exfiltration of large data sets
//! - **Opaque bindings**: Credentials never exposed to sandbox code

#[cfg(feature = "ast-validator")]
pub mod ast_validator;
pub mod audit;
pub mod error;
pub mod executor;
pub mod groups;
pub mod host;
pub mod ipc;
#[cfg(feature = "metrics")]
pub mod metrics;
pub mod ops;
pub mod pool;
pub mod redact;
pub mod stash;
pub mod validator;

pub use error::SandboxError;
pub use executor::{ExecutionMode, SandboxConfig, SandboxExecutor};

/// Trait for dispatching tool calls from the sandbox to downstream MCP servers.
///
/// Implementations hold credentials and manage connections to backend servers.
/// The sandbox code never sees tokens, file paths, or internal state — it calls
/// through opaque proxy objects that route here.
#[async_trait::async_trait]
pub trait ToolDispatcher: Send + Sync {
    /// Call a tool on a downstream server.
    ///
    /// - `server`: The server name (e.g., "github", "narsil")
    /// - `tool`: The tool identifier (e.g., "symbols.find", "issues.list")
    /// - `args`: The tool arguments as a JSON value
    async fn call_tool(
        &self,
        server: &str,
        tool: &str,
        args: serde_json::Value,
    ) -> Result<serde_json::Value, forge_error::DispatchError>;
}

/// Trait for dispatching resource reads from the sandbox to downstream MCP servers.
///
/// Resources are data objects (logs, files, database rows) exposed via MCP's
/// resources/read protocol. Unlike tool calls, resources are read-only.
#[async_trait::async_trait]
pub trait ResourceDispatcher: Send + Sync {
    /// Read a resource by URI from a downstream server.
    ///
    /// - `server`: The server name (e.g., "postgres", "github")
    /// - `uri`: The resource URI (e.g., "file:///logs/app.log")
    ///
    /// Returns the resource content as a JSON value.
    async fn read_resource(
        &self,
        server: &str,
        uri: &str,
    ) -> Result<serde_json::Value, forge_error::DispatchError>;
}

/// Trait for dispatching stash operations from the sandbox.
///
/// The stash is a per-session key/value store that persists across sandbox
/// executions within the same session. Entries are scoped by server group
/// for isolation.
#[async_trait::async_trait]
pub trait StashDispatcher: Send + Sync {
    /// Store a value under a key with an optional TTL.
    ///
    /// - `key`: Alphanumeric key (plus `_`, `-`, `.`, `:`) up to 256 chars
    /// - `value`: The JSON value to store
    /// - `ttl_secs`: TTL in seconds (0 = use default)
    /// - `current_group`: The server group of the current execution, if any
    async fn put(
        &self,
        key: &str,
        value: serde_json::Value,
        ttl_secs: Option<u32>,
        current_group: Option<String>,
    ) -> Result<serde_json::Value, forge_error::DispatchError>;

    /// Retrieve the value stored under a key.
    ///
    /// Returns `null` if the key does not exist or has expired.
    async fn get(
        &self,
        key: &str,
        current_group: Option<String>,
    ) -> Result<serde_json::Value, forge_error::DispatchError>;

    /// Delete the entry stored under a key.
    ///
    /// Returns `{"deleted": true}` if the entry was removed, `{"deleted": false}` otherwise.
    async fn delete(
        &self,
        key: &str,
        current_group: Option<String>,
    ) -> Result<serde_json::Value, forge_error::DispatchError>;

    /// List all keys visible to the current group.
    async fn keys(
        &self,
        current_group: Option<String>,
    ) -> Result<serde_json::Value, forge_error::DispatchError>;
}

#[cfg(test)]
mod feature_tests {
    /// Verify that the default feature set includes ast-validator.
    #[test]
    #[cfg(feature = "ast-validator")]
    fn ff_01_default_features_include_ast_validator() {
        // This test only compiles when ast-validator is enabled (the default).
        // If it disappears from default features, the test count will drop — caught by CI.
        let result = crate::ast_validator::validate_ast("async () => { return 1; }");
        assert!(result.is_ok());
    }

    /// Verify that `worker-pool` feature is on by default (v0.4.0+).
    #[test]
    #[cfg(feature = "worker-pool")]
    fn ff_02_worker_pool_is_default() {
        // worker-pool is default-on since v0.4.0.
        // Verify the pool module types are accessible.
        let _ = std::any::type_name::<crate::pool::WorkerPool>();
        let _ = std::any::type_name::<crate::pool::PoolConfig>();
    }

    /// Verify that `metrics` feature is on by default (v0.4.0+).
    #[test]
    #[cfg(feature = "metrics")]
    fn ff_03_metrics_is_default() {
        // metrics is default-on since v0.4.0.
        // Verify the metrics module types are accessible.
        let _ = std::any::type_name::<crate::metrics::ForgeMetrics>();
    }

    /// Verify that the crate has the expected module layout regardless of features.
    #[test]
    fn ff_04_core_modules_always_available() {
        // These types must be available regardless of feature flags.
        let _ = std::any::type_name::<crate::SandboxError>();
        let _ = std::any::type_name::<crate::SandboxConfig>();
        let _ = std::any::type_name::<crate::ExecutionMode>();
    }

    /// Verify that minimal feature set disables worker-pool.
    /// Only compiles under `--no-default-features`.
    #[test]
    #[cfg(not(feature = "worker-pool"))]
    fn ff_05_minimal_disables_pool() {
        // Under --no-default-features, worker-pool should be off.
    }

    /// Verify that minimal feature set disables metrics.
    /// Only compiles under `--no-default-features`.
    #[test]
    #[cfg(not(feature = "metrics"))]
    fn ff_06_minimal_disables_metrics() {
        // Under --no-default-features, metrics should be off.
    }
}