Skip to main content

av_sandbox/
sandbox.rs

1//! The sandbox pipeline: parse → schema → policy → budget, with timing.
2
3use crate::policy::{PolicyDecision, PolicyEngine};
4use crate::rpc::{authorization_error, parse_tool_call, RpcError};
5use av_state::{ActionBudget, BudgetDecision, BudgetSpec, StateStore};
6use serde_json::Value;
7use std::collections::HashMap;
8
9/// Sandbox configuration.
10pub struct SandboxConfig {
11    /// Per-tool JSON Schemas for argument validation.
12    pub schemas: HashMap<String, Value>,
13    /// Action budget spec applied per session.
14    pub budget: BudgetSpec,
15    /// Argument field carrying a payout amount in USD (e.g. `amount_usd`).
16    /// When present on a call, it is charged against `max_payout_usd_micros`.
17    pub payout_field: String,
18    /// Reject tools without a configured schema.
19    pub require_schema: bool,
20}
21
22impl Default for SandboxConfig {
23    fn default() -> Self {
24        Self {
25            schemas: HashMap::new(),
26            budget: BudgetSpec::default(),
27            payout_field: "amount_usd".into(),
28            require_schema: false,
29        }
30    }
31}
32
33/// Verdict for one intercepted tool call.
34#[derive(Debug)]
35pub enum ToolVerdict {
36    /// Forward to the downstream tool server.
37    Allowed {
38        /// Tool name.
39        tool: String,
40        /// Remaining headroom under the binding budget dimension.
41        budget_remaining: u64,
42        /// Decision latency in microseconds (SLA surface, R23).
43        elapsed_us: u64,
44        /// Round-33 F1: payout amount (USD micros) that
45        /// `ActionBudget::try_tool_call` debited on this call. Threaded
46        /// out so a lost `execution.claim()` race in the harness can
47        /// call [`ActionBudget::refund_tool_call`] with the exact same
48        /// amount, closing the round-32 F3 concurrent-MCP budget
49        /// double-spend.
50        payout_micros: u64,
51    },
52    /// Block; respond to the agent with `response`.
53    Blocked {
54        /// Tool name (`<unparsed>` when the payload never parsed).
55        tool: String,
56        /// Which gate blocked: `parse` | `schema` | `policy` | `budget`.
57        stage: &'static str,
58        /// Human/machine-readable reason.
59        reason: String,
60        /// Ready-to-send JSON-RPC authorization error.
61        response: Value,
62        /// Decision latency in microseconds.
63        elapsed_us: u64,
64    },
65}
66
67impl ToolVerdict {
68    /// True when the call may proceed.
69    pub fn is_allowed(&self) -> bool {
70        matches!(self, Self::Allowed { .. })
71    }
72
73    /// Decision latency in microseconds.
74    pub fn elapsed_us(&self) -> u64 {
75        match self {
76            Self::Allowed { elapsed_us, .. } | Self::Blocked { elapsed_us, .. } => *elapsed_us,
77        }
78    }
79}
80
81/// The sandbox: compiled schemas + policy chain + budget spec.
82pub struct Sandbox {
83    validators: HashMap<String, jsonschema::Validator>,
84    policies: Vec<Box<dyn PolicyEngine>>,
85    config: SandboxConfig,
86}
87
88impl Sandbox {
89    /// Build a sandbox, compiling every tool schema up front (a schema that
90    /// fails to compile is a configuration error surfaced at boot, not a
91    /// silently-skipped validation at request time).
92    pub fn new(config: SandboxConfig, policies: Vec<Box<dyn PolicyEngine>>) -> Result<Self, String> {
93        let mut validators = HashMap::new();
94        for (tool, schema) in &config.schemas {
95            let v = jsonschema::validator_for(schema)
96                .map_err(|e| format!("schema for tool {tool:?} does not compile: {e}"))?;
97            validators.insert(tool.clone(), v);
98        }
99        Ok(Self {
100            validators,
101            policies,
102            config,
103        })
104    }
105
106    /// Evaluate a raw MCP payload for `session`.
107    pub fn check(&self, store: &dyn StateStore, session: &str, raw: &[u8]) -> ToolVerdict {
108        let started = std::time::Instant::now();
109        let elapsed = |s: std::time::Instant| u64::try_from(s.elapsed().as_micros()).unwrap_or(u64::MAX);
110
111        // Gate 1: parse.
112        let req = match parse_tool_call(raw) {
113            Ok(r) => r,
114            Err(e) => {
115                let (stage, reason) = match &e {
116                    RpcError::NotToolCall(_) => ("parse", format!("passthrough refused: {e}")),
117                    _ => ("parse", e.to_string()),
118                };
119                return ToolVerdict::Blocked {
120                    tool: "<unparsed>".into(),
121                    stage,
122                    reason: reason.clone(),
123                    response: authorization_error(None, &reason),
124                    elapsed_us: elapsed(started),
125                };
126            }
127        };
128
129        // Gate 2: schema.
130        if let Some(validator) = self.validators.get(&req.tool) {
131            // Bounded work: a hostile client cannot force unbounded String
132            // allocation by sending pathologically-invalid arguments.
133            let errors: Vec<String> = validator
134                .iter_errors(&req.arguments)
135                .take(3)
136                .map(|e| e.to_string())
137                .collect();
138            if !errors.is_empty() {
139                let reason = format!("schema validation failed: {}", errors.join("; "));
140                return ToolVerdict::Blocked {
141                    tool: req.tool.clone(),
142                    stage: "schema",
143                    reason: reason.clone(),
144                    response: authorization_error(req.id.as_ref(), &reason),
145                    elapsed_us: elapsed(started),
146                };
147            }
148        } else if self.config.require_schema {
149            let reason = format!("no argument schema configured for tool {:?}", req.tool);
150            return ToolVerdict::Blocked {
151                tool: req.tool.clone(),
152                stage: "schema",
153                reason: reason.clone(),
154                response: authorization_error(req.id.as_ref(), &reason),
155                elapsed_us: elapsed(started),
156            };
157        }
158
159        // Gate 3: policy chain (first deny wins).
160        for policy in &self.policies {
161            if let PolicyDecision::Deny { reason } = policy.evaluate(&req.tool, &req.arguments) {
162                let reason = format!("policy {:?}: {reason}", policy.name());
163                return ToolVerdict::Blocked {
164                    tool: req.tool.clone(),
165                    stage: "policy",
166                    reason: reason.clone(),
167                    response: authorization_error(req.id.as_ref(), &reason),
168                    elapsed_us: elapsed(started),
169                };
170            }
171        }
172
173        // Gate 4: budget (atomic check-and-spend).
174        let payout_micros = match extract_payout_micros(&req.arguments, &self.config.payout_field) {
175            Ok(m) => m,
176            Err(reason) => {
177                return ToolVerdict::Blocked {
178                    tool: req.tool.clone(),
179                    stage: "budget",
180                    reason: reason.clone(),
181                    response: authorization_error(req.id.as_ref(), &reason),
182                    elapsed_us: elapsed(started),
183                }
184            }
185        };
186        let budget = ActionBudget::new(store, session, &self.config.budget);
187        match budget.try_tool_call(&req.tool, payout_micros) {
188            Ok(BudgetDecision::Allowed { remaining }) => ToolVerdict::Allowed {
189                tool: req.tool,
190                budget_remaining: remaining,
191                elapsed_us: elapsed(started),
192                // Round-33 F1: thread the actual debited amount so the
193                // harness can refund exactly this much on a lost
194                // execution.claim() race.
195                payout_micros,
196            },
197            Ok(BudgetDecision::Refused { limit, cap }) => {
198                let reason = format!("action budget exceeded: {limit} (cap {cap})");
199                ToolVerdict::Blocked {
200                    tool: req.tool.clone(),
201                    stage: "budget",
202                    reason: reason.clone(),
203                    response: authorization_error(req.id.as_ref(), &reason),
204                    elapsed_us: elapsed(started),
205                }
206            }
207            Err(e) => {
208                // State-store failure fails closed.
209                let reason = format!("budget check failed closed: {e}");
210                ToolVerdict::Blocked {
211                    tool: req.tool.clone(),
212                    stage: "budget",
213                    reason: reason.clone(),
214                    response: authorization_error(req.id.as_ref(), &reason),
215                    elapsed_us: elapsed(started),
216                }
217            }
218        }
219    }
220
221    /// Evaluate an arbitrary operation payload against the configured native
222    /// and WASM policy chain. Used for chat sanitization before compression.
223    pub fn sanitize(&self, operation: &str, payload: &Value) -> Result<(), String> {
224        for policy in &self.policies {
225            if let PolicyDecision::Deny { reason } = policy.evaluate(operation, payload) {
226                return Err(format!("policy {:?}: {reason}", policy.name()));
227            }
228        }
229        Ok(())
230    }
231}
232
233/// Extract a payout amount (USD float or integer) as micro-USD. Rejects
234/// negatives, NaN/Inf, and absurd magnitudes rather than truncating silently.
235fn extract_payout_micros(arguments: &Value, field: &str) -> Result<u64, String> {
236    let Some(v) = arguments.get(field) else {
237        return Ok(0);
238    };
239    let usd = v.as_f64().ok_or_else(|| format!("{field} must be a number"))?;
240    if !usd.is_finite() || usd < 0.0 {
241        return Err(format!("{field} must be a finite non-negative number, got {usd}"));
242    }
243    if usd > 1.0e12 {
244        return Err(format!("{field} of {usd} exceeds sanity bounds"));
245    }
246    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
247    Ok((usd * av_core::units::USD_MICROS_PER_DOLLAR as f64).round() as u64)
248}
249
250#[cfg(test)]
251mod tests {
252    #![allow(
253        clippy::unwrap_used,
254        clippy::expect_used,
255        clippy::panic,
256        clippy::indexing_slicing
257    )]
258
259    use super::*;
260    use crate::policy::NativePolicy;
261    use av_state::InMemoryStore;
262    use serde_json::json;
263
264    fn raw_call(tool: &str, args: Value) -> Vec<u8> {
265        serde_json::to_vec(&json!({
266            "jsonrpc": "2.0", "id": 1, "method": "tools/call",
267            "params": {"name": tool, "arguments": args}
268        }))
269        .unwrap()
270    }
271
272    fn sandbox() -> Sandbox {
273        let mut schemas = HashMap::new();
274        schemas.insert(
275            "db_write".to_owned(),
276            json!({
277                "type": "object",
278                "required": ["table", "row"],
279                "properties": {
280                    "table": {"type": "string", "minLength": 1},
281                    "row": {"type": "object"}
282                },
283                "additionalProperties": false
284            }),
285        );
286        let budget = BudgetSpec {
287            max_tool_calls: [("db_write".to_owned(), 3)].into_iter().collect(),
288            max_payout_usd_micros: Some(50_000_000),
289            max_total_tool_calls: Some(100),
290            max_tokens: None,
291        };
292        Sandbox::new(
293            SandboxConfig {
294                schemas,
295                budget,
296                payout_field: "amount_usd".into(),
297                require_schema: false,
298            },
299            vec![Box::new(NativePolicy::deny_tools(&["rm_rf"]))],
300        )
301        .unwrap()
302    }
303
304    #[test]
305    fn valid_call_allowed_with_latency() {
306        let store = InMemoryStore::new();
307        let sandbox = sandbox();
308        let v = sandbox.check(
309            &store,
310            "s",
311            &raw_call("db_write", json!({"table": "t", "row": {}})),
312        );
313        assert!(v.is_allowed(), "{v:?}");
314        // R23: block/allow decision < 5ms = 5000µs. A single debug-profile
315        // sample flakes under full-suite load (scheduler preemption between
316        // the two Instant reads), so take the minimum over several runs:
317        // one clean time slice is enough for a genuinely fast decision,
318        // while a real regression stays slow on every sample. Distinct
319        // sessions keep the per-session db_write budget (3) out of play.
320        // The release SLA gate and criterion bench own the authoritative
321        // measurement.
322        let min_elapsed_us = (0..10)
323            .map(|i| {
324                let verdict = sandbox.check(
325                    &store,
326                    &format!("s-latency-{i}"),
327                    &raw_call("db_write", json!({"table": "t", "row": {}})),
328                );
329                assert!(verdict.is_allowed(), "{verdict:?}");
330                verdict.elapsed_us()
331            })
332            .min()
333            .unwrap_or(u64::MAX);
334        assert!(min_elapsed_us < 5_000, "fastest decision took {min_elapsed_us}µs");
335    }
336
337    #[test]
338    fn schema_invalid_blocked() {
339        let store = InMemoryStore::new();
340        let s = sandbox();
341        // Missing required field.
342        let v = s.check(&store, "s", &raw_call("db_write", json!({"table": "t"})));
343        match &v {
344            ToolVerdict::Blocked { stage, response, .. } => {
345                assert_eq!(*stage, "schema");
346                assert_eq!(response["error"]["code"], -32001);
347            }
348            other => panic!("{other:?}"),
349        }
350        // Type confusion.
351        let v = s.check(
352            &store,
353            "s",
354            &raw_call("db_write", json!({"table": 42, "row": {}})),
355        );
356        assert!(!v.is_allowed());
357        // Extra field (additionalProperties: false).
358        let v = s.check(
359            &store,
360            "s",
361            &raw_call("db_write", json!({"table": "t", "row": {}, "backdoor": true})),
362        );
363        assert!(!v.is_allowed());
364    }
365
366    #[test]
367    fn policy_denied_blocked() {
368        let store = InMemoryStore::new();
369        let v = sandbox().check(&store, "s", &raw_call("rm_rf", json!({})));
370        match v {
371            ToolVerdict::Blocked { stage, reason, .. } => {
372                assert_eq!(stage, "policy");
373                assert!(reason.contains("deny-listed"), "{reason}");
374            }
375            other => panic!("{other:?}"),
376        }
377    }
378
379    #[test]
380    fn budget_enforced_across_calls() {
381        let store = InMemoryStore::new();
382        let s = sandbox();
383        let args = json!({"table": "t", "row": {}});
384        for _ in 0..3 {
385            assert!(s
386                .check(&store, "sess", &raw_call("db_write", args.clone()))
387                .is_allowed());
388        }
389        let v = s.check(&store, "sess", &raw_call("db_write", args));
390        match v {
391            ToolVerdict::Blocked { stage, reason, .. } => {
392                assert_eq!(stage, "budget");
393                assert!(reason.contains("db_write"), "{reason}");
394            }
395            other => panic!("{other:?}"),
396        }
397    }
398
399    #[test]
400    fn payout_cap_enforced_and_hostile_amounts_rejected() {
401        let store = InMemoryStore::new();
402        let s = sandbox();
403        assert!(s
404            .check(&store, "p", &raw_call("payout", json!({"amount_usd": 49.5})))
405            .is_allowed());
406        // Would cross $50 total.
407        let v = s.check(&store, "p", &raw_call("payout", json!({"amount_usd": 1.0})));
408        assert!(!v.is_allowed(), "{v:?}");
409        // Hostile numerics.
410        for bad in [
411            json!({"amount_usd": -5}),
412            json!({"amount_usd": "1e9"}),
413            json!({"amount_usd": 1e15}),
414        ] {
415            let v = s.check(&store, "p2", &raw_call("payout", bad));
416            assert!(!v.is_allowed(), "hostile payout accepted: {v:?}");
417        }
418    }
419
420    #[test]
421    fn unparsable_payload_blocked_at_parse() {
422        let store = InMemoryStore::new();
423        let v = sandbox().check(&store, "s", b"\xff\xfe not json");
424        match v {
425            ToolVerdict::Blocked { stage, tool, .. } => {
426                assert_eq!(stage, "parse");
427                assert_eq!(tool, "<unparsed>");
428            }
429            other => panic!("{other:?}"),
430        }
431    }
432
433    #[test]
434    fn tools_without_schema_pass_schema_gate() {
435        let store = InMemoryStore::new();
436        let v = sandbox().check(&store, "s", &raw_call("search", json!({"anything": [1, 2]})));
437        assert!(v.is_allowed(), "{v:?}");
438    }
439
440    #[test]
441    fn required_schema_mode_blocks_unknown_tools() {
442        let sandbox = Sandbox::new(
443            SandboxConfig {
444                require_schema: true,
445                ..SandboxConfig::default()
446            },
447            Vec::new(),
448        )
449        .unwrap();
450        let verdict = sandbox.check(&InMemoryStore::new(), "session", &raw_call("unknown", json!({})));
451        assert!(matches!(verdict, ToolVerdict::Blocked { stage: "schema", .. }));
452    }
453
454    #[test]
455    fn bad_boot_schema_is_a_boot_error() {
456        let mut schemas = HashMap::new();
457        schemas.insert("t".to_owned(), json!({"type": "not-a-type"}));
458        let err = Sandbox::new(
459            SandboxConfig {
460                schemas,
461                ..SandboxConfig::default()
462            },
463            vec![],
464        );
465        assert!(err.is_err(), "invalid schema must fail at boot, not be skipped");
466    }
467
468    #[test]
469    fn chat_payload_uses_the_same_policy_chain() {
470        let sandbox = Sandbox::new(
471            SandboxConfig::default(),
472            vec![Box::new(NativePolicy::new("no-secret", |operation, payload| {
473                if operation == "chat/completions" && payload.to_string().contains("secret") {
474                    PolicyDecision::Deny {
475                        reason: "secret content".into(),
476                    }
477                } else {
478                    PolicyDecision::Allow
479                }
480            }))],
481        )
482        .unwrap();
483        assert!(sandbox
484            .sanitize("chat/completions", &json!({"messages": ["safe"]}))
485            .is_ok());
486        assert!(sandbox
487            .sanitize("chat/completions", &json!({"messages": ["secret"]}))
488            .is_err());
489    }
490}
491
492#[cfg(test)]
493mod payout_boundary_tests {
494    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
495
496    use super::*;
497    use serde_json::json;
498
499    /// Mutation-run hardening (round 11): pin the payout parser's exact
500    /// bounds. `usd < 0.0` -> `<=` would refuse a legitimate zero payout;
501    /// `usd > 1.0e12` -> `>=`/`==` shift or invert the sanity ceiling.
502    #[test]
503    fn payout_bounds_are_exact() {
504        let extract = |v: serde_json::Value| extract_payout_micros(&json!({"amount_usd": v}), "amount_usd");
505        assert_eq!(extract(json!(0.0)).unwrap(), 0, "zero payout is legitimate");
506        assert_eq!(
507            extract(json!(1.0e12)).unwrap(),
508            1_000_000_000_000_u64 * av_core::units::USD_MICROS_PER_DOLLAR,
509            "the sanity ceiling itself is accepted"
510        );
511        let over = extract(json!(2.0e12));
512        assert!(
513            matches!(over, Err(ref m) if m.contains("sanity")),
514            "past the ceiling must be refused, got {over:?}"
515        );
516        let negative = extract(json!(-0.25));
517        assert!(matches!(negative, Err(ref m) if m.contains("non-negative")));
518    }
519}