1use 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
9pub struct SandboxConfig {
11 pub schemas: HashMap<String, Value>,
13 pub budget: BudgetSpec,
15 pub payout_field: String,
18 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#[derive(Debug)]
35pub enum ToolVerdict {
36 Allowed {
38 tool: String,
40 budget_remaining: u64,
42 elapsed_us: u64,
44 payout_micros: u64,
51 },
52 Blocked {
54 tool: String,
56 stage: &'static str,
58 reason: String,
60 response: Value,
62 elapsed_us: u64,
64 },
65}
66
67impl ToolVerdict {
68 pub fn is_allowed(&self) -> bool {
70 matches!(self, Self::Allowed { .. })
71 }
72
73 pub fn elapsed_us(&self) -> u64 {
75 match self {
76 Self::Allowed { elapsed_us, .. } | Self::Blocked { elapsed_us, .. } => *elapsed_us,
77 }
78 }
79}
80
81pub struct Sandbox {
83 validators: HashMap<String, jsonschema::Validator>,
84 policies: Vec<Box<dyn PolicyEngine>>,
85 config: SandboxConfig,
86}
87
88impl Sandbox {
89 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 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 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 if let Some(validator) = self.validators.get(&req.tool) {
131 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 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 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 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 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 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
233fn 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 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 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 let v = s.check(
352 &store,
353 "s",
354 &raw_call("db_write", json!({"table": 42, "row": {}})),
355 );
356 assert!(!v.is_allowed());
357 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 let v = s.check(&store, "p", &raw_call("payout", json!({"amount_usd": 1.0})));
408 assert!(!v.is_allowed(), "{v:?}");
409 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 #[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}