forge_sandbox/lib.rs
1#![warn(missing_docs)]
2
3//! # forge-sandbox
4//!
5//! V8 sandbox for the Forgemax Code Mode Gateway.
6//!
7//! Executes LLM-generated JavaScript in a deno_core isolate with no filesystem,
8//! network, or environment access. The only bridge to the host is through
9//! explicitly registered ops that dispatch to a [`ToolDispatcher`].
10//!
11//! ## Security model
12//!
13//! - **V8 isolate**: Same process-level isolation as Chrome tabs
14//! - **No ambient capabilities**: No fs, net, env, or child_process access
15//! - **Fresh runtime per call**: No state leakage between executions
16//! - **Pre-execution validation**: Banned patterns caught before reaching V8
17//! - **Timeout enforcement**: Execution killed after configurable deadline
18//! - **Output size limits**: Prevents exfiltration of large data sets
19//! - **Opaque bindings**: Credentials never exposed to sandbox code
20
21#[cfg(feature = "ast-validator")]
22pub mod ast_validator;
23pub mod audit;
24pub mod error;
25pub mod executor;
26pub mod groups;
27pub mod host;
28pub mod ipc;
29#[cfg(feature = "metrics")]
30pub mod metrics;
31pub mod ops;
32pub mod pool;
33pub mod redact;
34pub mod stash;
35pub mod validator;
36
37pub use error::SandboxError;
38pub use executor::{ExecutionMode, SandboxConfig, SandboxExecutor};
39
40/// Trait for dispatching tool calls from the sandbox to downstream MCP servers.
41///
42/// Implementations hold credentials and manage connections to backend servers.
43/// The sandbox code never sees tokens, file paths, or internal state — it calls
44/// through opaque proxy objects that route here.
45#[async_trait::async_trait]
46pub trait ToolDispatcher: Send + Sync {
47 /// Call a tool on a downstream server.
48 ///
49 /// - `server`: The server name (e.g., "github", "narsil")
50 /// - `tool`: The tool identifier (e.g., "symbols.find", "issues.list")
51 /// - `args`: The tool arguments as a JSON value
52 async fn call_tool(
53 &self,
54 server: &str,
55 tool: &str,
56 args: serde_json::Value,
57 ) -> Result<serde_json::Value, forge_error::DispatchError>;
58}
59
60/// Trait for dispatching resource reads from the sandbox to downstream MCP servers.
61///
62/// Resources are data objects (logs, files, database rows) exposed via MCP's
63/// resources/read protocol. Unlike tool calls, resources are read-only.
64#[async_trait::async_trait]
65pub trait ResourceDispatcher: Send + Sync {
66 /// Read a resource by URI from a downstream server.
67 ///
68 /// - `server`: The server name (e.g., "postgres", "github")
69 /// - `uri`: The resource URI (e.g., "file:///logs/app.log")
70 ///
71 /// Returns the resource content as a JSON value.
72 async fn read_resource(
73 &self,
74 server: &str,
75 uri: &str,
76 ) -> Result<serde_json::Value, forge_error::DispatchError>;
77}
78
79/// Trait for dispatching stash operations from the sandbox.
80///
81/// The stash is a per-session key/value store that persists across sandbox
82/// executions within the same session. Entries are scoped by server group
83/// for isolation.
84#[async_trait::async_trait]
85pub trait StashDispatcher: Send + Sync {
86 /// Store a value under a key with an optional TTL.
87 ///
88 /// - `key`: Alphanumeric key (plus `_`, `-`, `.`, `:`) up to 256 chars
89 /// - `value`: The JSON value to store
90 /// - `ttl_secs`: TTL in seconds (0 = use default)
91 /// - `current_group`: The server group of the current execution, if any
92 async fn put(
93 &self,
94 key: &str,
95 value: serde_json::Value,
96 ttl_secs: Option<u32>,
97 current_group: Option<String>,
98 ) -> Result<serde_json::Value, forge_error::DispatchError>;
99
100 /// Retrieve the value stored under a key.
101 ///
102 /// Returns `null` if the key does not exist or has expired.
103 async fn get(
104 &self,
105 key: &str,
106 current_group: Option<String>,
107 ) -> Result<serde_json::Value, forge_error::DispatchError>;
108
109 /// Delete the entry stored under a key.
110 ///
111 /// Returns `{"deleted": true}` if the entry was removed, `{"deleted": false}` otherwise.
112 async fn delete(
113 &self,
114 key: &str,
115 current_group: Option<String>,
116 ) -> Result<serde_json::Value, forge_error::DispatchError>;
117
118 /// List all keys visible to the current group.
119 async fn keys(
120 &self,
121 current_group: Option<String>,
122 ) -> Result<serde_json::Value, forge_error::DispatchError>;
123}
124
125#[cfg(test)]
126mod feature_tests {
127 /// Verify that the default feature set includes ast-validator.
128 #[test]
129 #[cfg(feature = "ast-validator")]
130 fn ff_01_default_features_include_ast_validator() {
131 // This test only compiles when ast-validator is enabled (the default).
132 // If it disappears from default features, the test count will drop — caught by CI.
133 let result = crate::ast_validator::validate_ast("async () => { return 1; }");
134 assert!(result.is_ok());
135 }
136
137 /// Verify that `worker-pool` feature is on by default (v0.4.0+).
138 #[test]
139 #[cfg(feature = "worker-pool")]
140 fn ff_02_worker_pool_is_default() {
141 // worker-pool is default-on since v0.4.0.
142 // Verify the pool module types are accessible.
143 let _ = std::any::type_name::<crate::pool::WorkerPool>();
144 let _ = std::any::type_name::<crate::pool::PoolConfig>();
145 }
146
147 /// Verify that `metrics` feature is on by default (v0.4.0+).
148 #[test]
149 #[cfg(feature = "metrics")]
150 fn ff_03_metrics_is_default() {
151 // metrics is default-on since v0.4.0.
152 // Verify the metrics module types are accessible.
153 let _ = std::any::type_name::<crate::metrics::ForgeMetrics>();
154 }
155
156 /// Verify that the crate has the expected module layout regardless of features.
157 #[test]
158 fn ff_04_core_modules_always_available() {
159 // These types must be available regardless of feature flags.
160 let _ = std::any::type_name::<crate::SandboxError>();
161 let _ = std::any::type_name::<crate::SandboxConfig>();
162 let _ = std::any::type_name::<crate::ExecutionMode>();
163 }
164
165 /// Verify that minimal feature set disables worker-pool.
166 /// Only compiles under `--no-default-features`.
167 #[test]
168 #[cfg(not(feature = "worker-pool"))]
169 fn ff_05_minimal_disables_pool() {
170 // Under --no-default-features, worker-pool should be off.
171 }
172
173 /// Verify that minimal feature set disables metrics.
174 /// Only compiles under `--no-default-features`.
175 #[test]
176 #[cfg(not(feature = "metrics"))]
177 fn ff_06_minimal_disables_metrics() {
178 // Under --no-default-features, metrics should be off.
179 }
180}