1use crate::denial_code::DenialCode;
4use crate::policy::{PolicyDecision, PolicyEngine};
5use crate::pushed::PushedRules;
6use crate::rpc::{denial_error, parse_tool_call, RpcError};
7use pv_state::{ActionBudget, BudgetDecision, BudgetSpec, StateStore};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12pub struct SandboxConfig {
14 pub schemas: HashMap<String, Value>,
16 pub budget: BudgetSpec,
18 pub payout_field: String,
21 pub require_schema: bool,
23}
24
25impl Default for SandboxConfig {
26 fn default() -> Self {
27 Self {
28 schemas: HashMap::new(),
29 budget: BudgetSpec::default(),
30 payout_field: "amount_usd".into(),
31 require_schema: false,
32 }
33 }
34}
35
36#[derive(Debug)]
38#[non_exhaustive]
39pub enum ToolVerdict {
40 Allowed {
42 tool: String,
44 budget_remaining: u64,
46 elapsed_us: u64,
48 payout_micros: u64,
55 principal_id: Option<String>,
62 },
63 Blocked {
65 tool: String,
67 stage: &'static str,
69 policy: Option<String>,
76 denial_code: DenialCode,
78 reason: String,
80 response: Value,
82 elapsed_us: u64,
84 },
85}
86
87impl ToolVerdict {
88 pub fn is_allowed(&self) -> bool {
90 matches!(self, Self::Allowed { .. })
91 }
92
93 pub fn elapsed_us(&self) -> u64 {
95 match self {
96 Self::Allowed { elapsed_us, .. } | Self::Blocked { elapsed_us, .. } => *elapsed_us,
97 }
98 }
99}
100
101pub struct Sandbox {
103 validators: HashMap<String, jsonschema::Validator>,
104 policies: Vec<Box<dyn PolicyEngine>>,
105 config: SandboxConfig,
106 pushed: parking_lot::RwLock<Arc<PushedRules>>,
110}
111
112impl Sandbox {
113 pub fn new(config: SandboxConfig, policies: Vec<Box<dyn PolicyEngine>>) -> Result<Self, String> {
117 let mut validators = HashMap::new();
118 for (tool, schema) in &config.schemas {
119 let v = jsonschema::options()
125 .should_validate_formats(true)
126 .build(schema)
127 .map_err(|e| format!("schema for tool {tool:?} does not compile: {e}"))?;
128 validators.insert(tool.clone(), v);
129 }
130 Ok(Self {
131 validators,
132 policies,
133 config,
134 pushed: parking_lot::RwLock::new(Arc::new(PushedRules::default())),
135 })
136 }
137
138 pub fn set_pushed(&self, rules: PushedRules) {
141 *self.pushed.write() = Arc::new(rules);
142 }
143
144 pub fn pushed(&self) -> Arc<PushedRules> {
146 Arc::clone(&self.pushed.read())
147 }
148
149 pub fn effective_budget(&self) -> BudgetSpec {
152 self.config.budget.tightened_by(&self.pushed().budget)
153 }
154
155 pub fn check(&self, store: &dyn StateStore, session: &str, raw: &[u8]) -> ToolVerdict {
157 self.check_with_principal(store, session, None, raw)
158 }
159
160 pub fn check_with_principal(
173 &self,
174 store: &dyn StateStore,
175 session: &str,
176 principal: Option<(&str, &BudgetSpec)>,
177 raw: &[u8],
178 ) -> ToolVerdict {
179 let started = std::time::Instant::now();
180 let elapsed = |s: std::time::Instant| u64::try_from(s.elapsed().as_micros()).unwrap_or(u64::MAX);
181 let pushed = self.pushed();
182
183 let req = match parse_tool_call(raw) {
185 Ok(r) => r,
186 Err(e) => {
187 let (stage, reason, denial_code) = match &e {
188 RpcError::NotToolCall(_) => (
192 "passthrough",
193 format!("passthrough refused: {e}"),
194 DenialCode::PassthroughRefused,
195 ),
196 _ => ("parse", e.to_string(), DenialCode::ParseError),
197 };
198 let id = crate::rpc::detectable_error_id(raw, &e);
205 return ToolVerdict::Blocked {
206 tool: "<unparsed>".into(),
207 stage,
208 policy: None,
209 denial_code,
210 reason,
211 response: crate::rpc::protocol_error(id.as_ref(), &e),
212 elapsed_us: elapsed(started),
213 };
214 }
215 };
216
217 if let Some(validator) = self.validators.get(&req.tool) {
219 let errors: Vec<String> = validator
222 .iter_errors(&req.arguments)
223 .take(3)
224 .map(|e| e.to_string())
225 .collect();
226 if !errors.is_empty() {
227 let reason = format!("schema validation failed: {}", errors.join("; "));
228 return ToolVerdict::Blocked {
229 tool: req.tool.clone(),
230 stage: "schema",
231 policy: None,
232 denial_code: DenialCode::SchemaViolation,
233 reason: reason.clone(),
234 response: denial_error(req.id.as_ref(), DenialCode::SchemaViolation, &reason),
235 elapsed_us: elapsed(started),
236 };
237 }
238 } else if self.config.require_schema {
239 let reason = format!("no argument schema configured for tool {:?}", req.tool);
240 return ToolVerdict::Blocked {
241 tool: req.tool.clone(),
242 stage: "schema",
243 policy: None,
244 denial_code: DenialCode::SchemaMissing,
245 reason: reason.clone(),
246 response: denial_error(req.id.as_ref(), DenialCode::SchemaMissing, &reason),
247 elapsed_us: elapsed(started),
248 };
249 }
250
251 for schema in pushed.schemas.iter().filter(|schema| schema.tool == req.tool) {
253 let errors = schema.errors(&req.arguments);
254 if !errors.is_empty() {
255 let reason = format!(
256 "policy {:?}: schema validation failed: {}",
257 schema.policy,
258 errors.join("; ")
259 );
260 return ToolVerdict::Blocked {
261 tool: req.tool.clone(),
262 stage: "schema",
263 policy: Some(schema.policy.clone()),
264 denial_code: DenialCode::SchemaViolation,
265 reason: reason.clone(),
266 response: denial_error(req.id.as_ref(), DenialCode::SchemaViolation, &reason),
267 elapsed_us: elapsed(started),
268 };
269 }
270 }
271
272 for policy in &self.policies {
274 if let PolicyDecision::Deny { reason } = policy.evaluate(&req.tool, &req.arguments) {
275 let reason = format!("policy {:?}: {reason}", policy.name());
276 return ToolVerdict::Blocked {
277 tool: req.tool.clone(),
278 stage: "policy",
279 policy: Some(policy.name().to_owned()),
280 denial_code: DenialCode::PolicyDenied,
281 reason: reason.clone(),
282 response: denial_error(req.id.as_ref(), DenialCode::PolicyDenied, &reason),
283 elapsed_us: elapsed(started),
284 };
285 }
286 }
287
288 for rule in &pushed.allowlists {
290 if let Some(reason) = rule.refuses(&req.tool) {
291 let reason = format!("policy {:?}: {reason}", rule.policy);
292 return ToolVerdict::Blocked {
293 tool: req.tool.clone(),
294 stage: "policy",
295 policy: Some(rule.policy.clone()),
296 denial_code: DenialCode::PolicyDenied,
297 reason: reason.clone(),
298 response: denial_error(req.id.as_ref(), DenialCode::PolicyDenied, &reason),
299 elapsed_us: elapsed(started),
300 };
301 }
302 }
303
304 let payout_micros = match extract_payout_micros(&req.arguments, &self.config.payout_field) {
306 Ok(m) => m,
307 Err(reason) => {
308 return ToolVerdict::Blocked {
309 tool: req.tool.clone(),
310 stage: "budget",
311 policy: None,
312 denial_code: DenialCode::PayoutInvalid,
313 reason: reason.clone(),
314 response: denial_error(req.id.as_ref(), DenialCode::PayoutInvalid, &reason),
315 elapsed_us: elapsed(started),
316 }
317 }
318 };
319 let effective_budget = self.config.budget.tightened_by(&pushed.budget);
320 let budget = ActionBudget::new(store, session, &effective_budget);
321 if let Some((principal_id, principal_spec)) = principal {
328 let principal_budget = ActionBudget::for_principal(store, principal_id, principal_spec);
329 match principal_budget.try_tool_call(&req.tool, payout_micros) {
330 Ok(BudgetDecision::Allowed { .. }) => {}
331 Ok(BudgetDecision::Refused { limit, cap }) => {
332 let reason = format!("principal action budget exceeded: {limit} (cap {cap})");
333 return ToolVerdict::Blocked {
334 tool: req.tool.clone(),
335 stage: "budget",
336 policy: None,
337 denial_code: DenialCode::BudgetExceeded,
338 reason: reason.clone(),
339 response: denial_error(req.id.as_ref(), DenialCode::BudgetExceeded, &reason),
340 elapsed_us: elapsed(started),
341 };
342 }
343 Err(e) => {
344 let reason = format!("principal budget check failed closed: {e}");
345 return ToolVerdict::Blocked {
346 tool: req.tool.clone(),
347 stage: "budget",
348 policy: None,
349 denial_code: DenialCode::BudgetError,
350 reason: reason.clone(),
351 response: denial_error(req.id.as_ref(), DenialCode::BudgetError, &reason),
352 elapsed_us: elapsed(started),
353 };
354 }
355 }
356 match budget.try_tool_call(&req.tool, payout_micros) {
357 Ok(BudgetDecision::Allowed { remaining }) => {
358 return ToolVerdict::Allowed {
359 tool: req.tool,
360 budget_remaining: remaining,
361 elapsed_us: elapsed(started),
362 payout_micros,
363 principal_id: Some(principal_id.to_owned()),
364 };
365 }
366 Ok(BudgetDecision::Refused { limit, cap }) => {
367 principal_budget.refund_tool_call(&req.tool, payout_micros);
370 let policy = pushed.budget_policy_for(&self.config.budget, &limit, cap);
371 let reason = match &policy {
372 Some(name) => format!("policy {name:?}: action budget exceeded: {limit} (cap {cap})"),
373 None => format!("action budget exceeded: {limit} (cap {cap})"),
374 };
375 return ToolVerdict::Blocked {
376 tool: req.tool.clone(),
377 stage: "budget",
378 policy,
379 denial_code: DenialCode::BudgetExceeded,
380 reason: reason.clone(),
381 response: denial_error(req.id.as_ref(), DenialCode::BudgetExceeded, &reason),
382 elapsed_us: elapsed(started),
383 };
384 }
385 Err(e) => {
386 principal_budget.refund_tool_call(&req.tool, payout_micros);
387 let reason = format!("budget check failed closed: {e}");
388 return ToolVerdict::Blocked {
389 tool: req.tool.clone(),
390 stage: "budget",
391 policy: None,
392 denial_code: DenialCode::BudgetError,
393 reason: reason.clone(),
394 response: denial_error(req.id.as_ref(), DenialCode::BudgetError, &reason),
395 elapsed_us: elapsed(started),
396 };
397 }
398 }
399 }
400 match budget.try_tool_call(&req.tool, payout_micros) {
401 Ok(BudgetDecision::Allowed { remaining }) => ToolVerdict::Allowed {
402 tool: req.tool,
403 budget_remaining: remaining,
404 elapsed_us: elapsed(started),
405 payout_micros,
409 principal_id: None,
410 },
411 Ok(BudgetDecision::Refused { limit, cap }) => {
412 let policy = pushed.budget_policy_for(&self.config.budget, &limit, cap);
413 let reason = match &policy {
414 Some(name) => format!("policy {name:?}: action budget exceeded: {limit} (cap {cap})"),
415 None => format!("action budget exceeded: {limit} (cap {cap})"),
416 };
417 ToolVerdict::Blocked {
418 tool: req.tool.clone(),
419 stage: "budget",
420 policy,
421 denial_code: DenialCode::BudgetExceeded,
422 reason: reason.clone(),
423 response: denial_error(req.id.as_ref(), DenialCode::BudgetExceeded, &reason),
424 elapsed_us: elapsed(started),
425 }
426 }
427 Err(e) => {
428 let reason = format!("budget check failed closed: {e}");
430 ToolVerdict::Blocked {
431 tool: req.tool.clone(),
432 stage: "budget",
433 policy: None,
434 denial_code: DenialCode::BudgetError,
435 reason: reason.clone(),
436 response: denial_error(req.id.as_ref(), DenialCode::BudgetError, &reason),
437 elapsed_us: elapsed(started),
438 }
439 }
440 }
441 }
442
443 pub fn sanitize(&self, operation: &str, payload: &Value) -> Result<(), String> {
446 for policy in &self.policies {
447 if let PolicyDecision::Deny { reason } = policy.evaluate(operation, payload) {
448 return Err(format!("policy {:?}: {reason}", policy.name()));
449 }
450 }
451 Ok(())
452 }
453}
454
455fn extract_payout_micros(arguments: &Value, field: &str) -> Result<u64, String> {
458 let Some(v) = arguments.get(field) else {
459 return Ok(0);
460 };
461 let usd = v.as_f64().ok_or_else(|| format!("{field} must be a number"))?;
462 if !usd.is_finite() || usd < 0.0 {
463 return Err(format!("{field} must be a finite non-negative number, got {usd}"));
464 }
465 if usd > 1.0e12 {
466 return Err(format!("{field} of {usd} exceeds sanity bounds"));
467 }
468 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
477 Ok((usd * pv_core::units::USD_MICROS_PER_DOLLAR as f64).ceil() as u64)
478}
479
480#[cfg(test)]
481mod tests {
482 #![allow(
483 clippy::unwrap_used,
484 clippy::expect_used,
485 clippy::panic,
486 clippy::indexing_slicing
487 )]
488
489 use super::*;
490 use crate::policy::NativePolicy;
491 use pv_state::InMemoryStore;
492 use serde_json::json;
493
494 fn raw_call(tool: &str, args: Value) -> Vec<u8> {
495 serde_json::to_vec(&json!({
496 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
497 "params": {"name": tool, "arguments": args}
498 }))
499 .unwrap()
500 }
501
502 fn sandbox() -> Sandbox {
503 let mut schemas = HashMap::new();
504 schemas.insert(
505 "db_write".to_owned(),
506 json!({
507 "type": "object",
508 "required": ["table", "row"],
509 "properties": {
510 "table": {"type": "string", "minLength": 1},
511 "row": {"type": "object"}
512 },
513 "additionalProperties": false
514 }),
515 );
516 let budget = BudgetSpec {
517 max_tool_calls: [("db_write".to_owned(), 3)].into_iter().collect(),
518 max_payout_usd_micros: Some(50_000_000),
519 max_total_tool_calls: Some(100),
520 max_tokens: None,
521 };
522 Sandbox::new(
523 SandboxConfig {
524 schemas,
525 budget,
526 payout_field: "amount_usd".into(),
527 require_schema: false,
528 },
529 vec![Box::new(NativePolicy::deny_tools(&["rm_rf"]))],
530 )
531 .unwrap()
532 }
533
534 fn blocked_policy(verdict: &ToolVerdict) -> (Option<String>, &'static str) {
535 match verdict {
536 ToolVerdict::Blocked { policy, stage, .. } => (policy.clone(), *stage),
537 other => panic!("expected a block, got {other:?}"),
538 }
539 }
540
541 #[test]
542 fn pushed_rules_add_restrictions_and_name_the_console_policy() {
543 let s = sandbox();
544 let store = InMemoryStore::new();
545 assert!(s
547 .check(&store, "sess-p", &raw_call("send_wire", json!({})))
548 .is_allowed());
549 let mut rules = PushedRules {
550 etag: Some("ab12".into()),
551 budget_policies: vec!["finance.calls".into()],
552 ..PushedRules::default()
553 };
554 rules.budget.max_total_tool_calls = Some(3);
555 rules.allowlists.push(crate::pushed::PushedAllowlist {
556 policy: "runtime.no_wires".into(),
557 scope: crate::pushed::ToolPattern::from_scope("tool.*").unwrap(),
558 allow_only: None,
559 deny: ["send_wire".to_owned()].into_iter().collect(),
560 });
561 rules.schemas.push(
562 crate::pushed::PushedSchema::compile(
563 "procurement.vendors",
564 "create_purchase_order",
565 &json!({"type": "object", "required": ["vendor"], "properties": {"vendor": {"enum": ["Contoso"]}}}),
566 )
567 .unwrap(),
568 );
569 s.set_pushed(rules);
570 assert_eq!(s.pushed().etag.as_deref(), Some("ab12"));
571 assert_eq!(
572 s.effective_budget().max_total_tool_calls,
573 Some(3),
574 "pushed 3 beats local 100"
575 );
576 assert_eq!(
577 s.effective_budget().max_tool_calls.get("db_write"),
578 Some(&3),
579 "local caps survive"
580 );
581
582 let wire = s.check(&store, "sess-q", &raw_call("send_wire", json!({})));
583 assert_eq!(blocked_policy(&wire), (Some("runtime.no_wires".into()), "policy"));
584 let bad_vendor = s.check(
585 &store,
586 "sess-q",
587 &raw_call("create_purchase_order", json!({"vendor": "Initech"})),
588 );
589 assert_eq!(
590 blocked_policy(&bad_vendor),
591 (Some("procurement.vendors".into()), "schema")
592 );
593 assert!(s
594 .check(
595 &store,
596 "sess-q",
597 &raw_call("create_purchase_order", json!({"vendor": "Contoso"}))
598 )
599 .is_allowed());
600 let local_schema = s.check(&store, "sess-q", &raw_call("db_write", json!({"table": ""})));
602 assert_eq!(blocked_policy(&local_schema), (None, "schema"));
603 assert!(s
604 .check(&store, "sess-q", &raw_call("search", json!({})))
605 .is_allowed());
606 assert!(s
607 .check(&store, "sess-q", &raw_call("search", json!({})))
608 .is_allowed());
609 let over = s.check(&store, "sess-q", &raw_call("search", json!({})));
611 assert_eq!(blocked_policy(&over), (Some("finance.calls".into()), "budget"));
612
613 s.set_pushed(PushedRules::default());
615 assert!(s
616 .check(&store, "sess-r", &raw_call("send_wire", json!({})))
617 .is_allowed());
618 assert_eq!(s.effective_budget().max_total_tool_calls, Some(100));
619 }
620
621 #[test]
622 fn valid_call_allowed_with_latency() {
623 let store = InMemoryStore::new();
624 let sandbox = sandbox();
625 let v = sandbox.check(
626 &store,
627 "s",
628 &raw_call("db_write", json!({"table": "t", "row": {}})),
629 );
630 assert!(v.is_allowed(), "{v:?}");
631 let min_elapsed_us = (0..10)
640 .map(|i| {
641 let verdict = sandbox.check(
642 &store,
643 &format!("s-latency-{i}"),
644 &raw_call("db_write", json!({"table": "t", "row": {}})),
645 );
646 assert!(verdict.is_allowed(), "{verdict:?}");
647 verdict.elapsed_us()
648 })
649 .min()
650 .unwrap_or(u64::MAX);
651 assert!(min_elapsed_us < 5_000, "fastest decision took {min_elapsed_us}µs");
652 }
653
654 #[test]
655 fn schema_invalid_blocked() {
656 let store = InMemoryStore::new();
657 let s = sandbox();
658 let v = s.check(&store, "s", &raw_call("db_write", json!({"table": "t"})));
660 match &v {
661 ToolVerdict::Blocked { stage, response, .. } => {
662 assert_eq!(*stage, "schema");
663 assert_eq!(response["error"]["code"], -32001);
664 }
665 other => panic!("{other:?}"),
666 }
667 let v = s.check(
669 &store,
670 "s",
671 &raw_call("db_write", json!({"table": 42, "row": {}})),
672 );
673 assert!(!v.is_allowed());
674 let v = s.check(
676 &store,
677 "s",
678 &raw_call("db_write", json!({"table": "t", "row": {}, "backdoor": true})),
679 );
680 assert!(!v.is_allowed());
681 }
682
683 #[test]
684 fn policy_denied_blocked() {
685 let store = InMemoryStore::new();
686 let v = sandbox().check(&store, "s", &raw_call("rm_rf", json!({})));
687 match v {
688 ToolVerdict::Blocked {
689 stage,
690 policy,
691 reason,
692 ..
693 } => {
694 assert_eq!(stage, "policy");
695 assert!(reason.contains("deny-listed"), "{reason}");
696 assert_eq!(policy.as_deref(), Some("deny_tools"));
699 }
700 other => panic!("{other:?}"),
701 }
702 }
703
704 #[test]
705 fn non_policy_gates_carry_no_policy_attribution() {
706 let store = InMemoryStore::new();
707 let parse = sandbox().check(&store, "s", b"not json");
709 match parse {
710 ToolVerdict::Blocked { stage, policy, .. } => {
711 assert_eq!(stage, "parse");
712 assert!(policy.is_none(), "parse gate must not invent a policy name");
713 }
714 other => panic!("{other:?}"),
715 }
716 }
717
718 #[test]
723 fn blocked_parse_verdict_echoes_the_request_id() {
724 let store = InMemoryStore::new();
725 let raw = br#"{"jsonrpc":"2.0","id":42,"method":"initialize","params":{}}"#;
726 match sandbox().check(&store, "s", raw) {
727 ToolVerdict::Blocked {
728 response,
729 stage,
730 reason,
731 ..
732 } => {
733 assert_eq!(response["id"], json!(42), "{response}");
734 assert_eq!(stage, "passthrough");
740 assert!(reason.contains("passthrough refused"), "{reason}");
741 }
742 other => panic!("{other:?}"),
743 }
744 match sandbox().check(&store, "s", b"not json") {
746 ToolVerdict::Blocked { response, stage, .. } => {
747 assert_eq!(response["id"], serde_json::Value::Null, "{response}");
748 assert_eq!(stage, "parse");
749 }
750 other => panic!("{other:?}"),
751 }
752 }
753
754 #[test]
755 fn budget_enforced_across_calls() {
756 let store = InMemoryStore::new();
757 let s = sandbox();
758 let args = json!({"table": "t", "row": {}});
759 for _ in 0..3 {
760 assert!(s
761 .check(&store, "sess", &raw_call("db_write", args.clone()))
762 .is_allowed());
763 }
764 let v = s.check(&store, "sess", &raw_call("db_write", args));
765 match v {
766 ToolVerdict::Blocked { stage, reason, .. } => {
767 assert_eq!(stage, "budget");
768 assert!(reason.contains("db_write"), "{reason}");
769 }
770 other => panic!("{other:?}"),
771 }
772 }
773
774 #[test]
782 fn allowed_verdict_threads_principal_id_and_refund_restores_the_ledger() {
783 let store = InMemoryStore::new();
784 let s = sandbox();
785 let args = json!({"amount_usd": 1.0});
786 let principal_spec = BudgetSpec {
787 max_tool_calls: std::collections::BTreeMap::new(),
788 max_payout_usd_micros: Some(50_000_000),
789 max_total_tool_calls: Some(1),
790 max_tokens: None,
791 };
792 match s.check(&store, "sess-a", &raw_call("payout", args.clone())) {
794 ToolVerdict::Allowed { principal_id, .. } => assert_eq!(principal_id, None),
795 other => panic!("{other:?}"),
796 }
797 let verdict = s.check_with_principal(
800 &store,
801 "sess-b",
802 Some(("principal-1", &principal_spec)),
803 &raw_call("payout", args.clone()),
804 );
805 let (tool, payout_micros, principal_id) = match verdict {
806 ToolVerdict::Allowed {
807 tool,
808 payout_micros,
809 principal_id,
810 ..
811 } => (tool, payout_micros, principal_id),
812 other => panic!("{other:?}"),
813 };
814 assert_eq!(principal_id.as_deref(), Some("principal-1"));
815 assert_eq!(payout_micros, 1_000_000);
816 ActionBudget::new(&store, "sess-b", &s.config.budget).refund_tool_call(&tool, payout_micros);
820 ActionBudget::for_principal(&store, "principal-1", &principal_spec)
821 .refund_tool_call(&tool, payout_micros);
822 let retry = s.check_with_principal(
823 &store,
824 "sess-b",
825 Some(("principal-1", &principal_spec)),
826 &raw_call("payout", args),
827 );
828 assert!(
829 retry.is_allowed(),
830 "principal ledger did not heal after the compensating refund: {retry:?}"
831 );
832 }
833
834 #[test]
835 fn payout_cap_enforced_and_hostile_amounts_rejected() {
836 let store = InMemoryStore::new();
837 let s = sandbox();
838 assert!(s
839 .check(&store, "p", &raw_call("payout", json!({"amount_usd": 49.5})))
840 .is_allowed());
841 let v = s.check(&store, "p", &raw_call("payout", json!({"amount_usd": 1.0})));
843 assert!(!v.is_allowed(), "{v:?}");
844 for bad in [
846 json!({"amount_usd": -5}),
847 json!({"amount_usd": "1e9"}),
848 json!({"amount_usd": 1e15}),
849 ] {
850 let v = s.check(&store, "p2", &raw_call("payout", bad));
851 assert!(!v.is_allowed(), "hostile payout accepted: {v:?}");
852 }
853 }
854
855 #[test]
856 fn unparsable_payload_blocked_at_parse() {
857 let store = InMemoryStore::new();
858 let v = sandbox().check(&store, "s", b"\xff\xfe not json");
859 match v {
860 ToolVerdict::Blocked { stage, tool, .. } => {
861 assert_eq!(stage, "parse");
862 assert_eq!(tool, "<unparsed>");
863 }
864 other => panic!("{other:?}"),
865 }
866 }
867
868 #[test]
869 fn tools_without_schema_pass_schema_gate() {
870 let store = InMemoryStore::new();
871 let v = sandbox().check(&store, "s", &raw_call("search", json!({"anything": [1, 2]})));
872 assert!(v.is_allowed(), "{v:?}");
873 }
874
875 #[test]
876 fn required_schema_mode_blocks_unknown_tools() {
877 let sandbox = Sandbox::new(
878 SandboxConfig {
879 require_schema: true,
880 ..SandboxConfig::default()
881 },
882 Vec::new(),
883 )
884 .unwrap();
885 let verdict = sandbox.check(&InMemoryStore::new(), "session", &raw_call("unknown", json!({})));
886 assert!(matches!(verdict, ToolVerdict::Blocked { stage: "schema", .. }));
887 }
888
889 #[test]
890 fn bad_boot_schema_is_a_boot_error() {
891 let mut schemas = HashMap::new();
892 schemas.insert("t".to_owned(), json!({"type": "not-a-type"}));
893 let err = Sandbox::new(
894 SandboxConfig {
895 schemas,
896 ..SandboxConfig::default()
897 },
898 vec![],
899 );
900 assert!(err.is_err(), "invalid schema must fail at boot, not be skipped");
901 }
902
903 #[test]
904 fn chat_payload_uses_the_same_policy_chain() {
905 let sandbox = Sandbox::new(
906 SandboxConfig::default(),
907 vec![Box::new(NativePolicy::new("no-secret", |operation, payload| {
908 if operation == "chat/completions" && payload.to_string().contains("secret") {
909 PolicyDecision::Deny {
910 reason: "secret content".into(),
911 }
912 } else {
913 PolicyDecision::Allow
914 }
915 }))],
916 )
917 .unwrap();
918 assert!(sandbox
919 .sanitize("chat/completions", &json!({"messages": ["safe"]}))
920 .is_ok());
921 assert!(sandbox
922 .sanitize("chat/completions", &json!({"messages": ["secret"]}))
923 .is_err());
924 }
925}
926
927#[cfg(test)]
928mod payout_boundary_tests {
929 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
930
931 use super::*;
932 use serde_json::json;
933
934 #[test]
938 fn payout_bounds_are_exact() {
939 let extract = |v: serde_json::Value| extract_payout_micros(&json!({"amount_usd": v}), "amount_usd");
940 assert_eq!(extract(json!(0.0)).unwrap(), 0, "zero payout is legitimate");
941 assert_eq!(
942 extract(json!(1.0e12)).unwrap(),
943 1_000_000_000_000_u64 * pv_core::units::USD_MICROS_PER_DOLLAR,
944 "the sanity ceiling itself is accepted"
945 );
946 let over = extract(json!(2.0e12));
947 assert!(
948 matches!(over, Err(ref m) if m.contains("sanity")),
949 "past the ceiling must be refused, got {over:?}"
950 );
951 let negative = extract(json!(-0.25));
952 assert!(matches!(negative, Err(ref m) if m.contains("non-negative")));
953 assert_eq!(
959 extract(json!(0.0000004)).unwrap(),
960 1,
961 "positive sub-micro payouts must cost at least one micro-USD"
962 );
963 assert_eq!(extract(json!(0.0000019)).unwrap(), 2, "ceil, not round");
964 }
965}