Skip to main content

pv_sandbox/
sandbox.rs

1//! The sandbox pipeline: parse → schema → policy → budget, with timing.
2
3use 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
12/// Sandbox configuration.
13pub struct SandboxConfig {
14    /// Per-tool JSON Schemas for argument validation.
15    pub schemas: HashMap<String, Value>,
16    /// Action budget spec applied per session.
17    pub budget: BudgetSpec,
18    /// Argument field carrying a payout amount in USD (e.g. `amount_usd`).
19    /// When present on a call, it is charged against `max_payout_usd_micros`.
20    pub payout_field: String,
21    /// Reject tools without a configured schema.
22    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/// Verdict for one intercepted tool call.
37#[derive(Debug)]
38#[non_exhaustive]
39pub enum ToolVerdict {
40    /// Forward to the downstream tool server.
41    Allowed {
42        /// Tool name.
43        tool: String,
44        /// Remaining headroom under the binding budget dimension.
45        budget_remaining: u64,
46        /// Decision latency in microseconds (SLA surface, R23).
47        elapsed_us: u64,
48        /// Payout amount (USD micros) that
49        /// `ActionBudget::try_tool_call` debited on this call. Threaded
50        /// out so a lost `execution.claim()` race in the harness can
51        /// call [`ActionBudget::refund_tool_call`] with the exact same
52        /// amount, closing the concurrent-MCP budget
53        /// double-spend.
54        payout_micros: u64,
55        /// Principal id whose ledger was ALSO debited (when a
56        /// principal-scoped budget is bound). Threaded out so every
57        /// harness failure path that refunds the session ledger can
58        /// mirror the refund on the principal ledger — without this,
59        /// each lost claim race / saturated worker permanently consumed
60        /// principal quota with zero tools executed.
61        principal_id: Option<String>,
62    },
63    /// Block; respond to the agent with `response`.
64    Blocked {
65        /// Tool name (`<unparsed>` when the payload never parsed).
66        tool: String,
67        /// Which gate blocked: `parse` | `schema` | `policy` | `budget`.
68        stage: &'static str,
69        /// Name of the policy engine that denied, when the block came
70        /// from the named policy chain (`stage: "policy"`). Parse,
71        /// schema, and budget gates are configured limits rather than
72        /// named policies, so they stay `None` — the console's
73        /// per-policy attribution must never invent a policy that the
74        /// operator can't find in their policy inventory.
75        policy: Option<String>,
76        /// Structured denial code for machine consumption.
77        denial_code: DenialCode,
78        /// Human/machine-readable reason.
79        reason: String,
80        /// Ready-to-send JSON-RPC authorization error.
81        response: Value,
82        /// Decision latency in microseconds.
83        elapsed_us: u64,
84    },
85}
86
87impl ToolVerdict {
88    /// True when the call may proceed.
89    pub fn is_allowed(&self) -> bool {
90        matches!(self, Self::Allowed { .. })
91    }
92
93    /// Decision latency in microseconds.
94    pub fn elapsed_us(&self) -> u64 {
95        match self {
96            Self::Allowed { elapsed_us, .. } | Self::Blocked { elapsed_us, .. } => *elapsed_us,
97        }
98    }
99}
100
101/// The sandbox: compiled schemas + policy chain + budget spec.
102pub struct Sandbox {
103    validators: HashMap<String, jsonschema::Validator>,
104    policies: Vec<Box<dyn PolicyEngine>>,
105    config: SandboxConfig,
106    /// Console-pushed rules, swapped in whole when a new bundle is
107    /// applied. Every check reads one snapshot, so a call never sees half
108    /// of an old bundle and half of a new one.
109    pushed: parking_lot::RwLock<Arc<PushedRules>>,
110}
111
112impl Sandbox {
113    /// Build a sandbox, compiling every tool schema up front (a schema that
114    /// fails to compile is a configuration error surfaced at boot, not a
115    /// silently-skipped validation at request time).
116    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            // Enforce `format` keywords. In
120            // jsonschema 0.49 / draft 2020-12, `format` is
121            // annotation-only unless opted in — an operator writing
122            // `"format": "uri"` into a tool-argument schema expected an
123            // enforced gate, not a no-op.
124            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    /// Replace the console-pushed rules. They only ever add restrictions
139    /// (see [`crate::pushed`]).
140    pub fn set_pushed(&self, rules: PushedRules) {
141        *self.pushed.write() = Arc::new(rules);
142    }
143
144    /// The console-pushed rules currently in force.
145    pub fn pushed(&self) -> Arc<PushedRules> {
146        Arc::clone(&self.pushed.read())
147    }
148
149    /// The local budget tightened by any pushed budget: the spec every
150    /// session ledger is checked against.
151    pub fn effective_budget(&self) -> BudgetSpec {
152        self.config.budget.tightened_by(&self.pushed().budget)
153    }
154
155    /// Evaluate a raw MCP payload for `session`.
156    pub fn check(&self, store: &dyn StateStore, session: &str, raw: &[u8]) -> ToolVerdict {
157        self.check_with_principal(store, session, None, raw)
158    }
159
160    /// Same as [`Self::check`] but layers a principal-scoped budget check
161    /// on top of the session-scoped one. When `principal` is `Some(spec)`,
162    /// the tool call is refused unless BOTH ledgers admit the spend; the
163    /// principal ledger persists across every session belonging to the
164    /// same identity, which is what makes header-rotation attacks
165    /// (resetting the session bucket by minting a new session id)
166    /// visible.
167    ///
168    /// The two ledgers commit independently: if the principal check
169    /// passes and the session check fails, the principal debit is
170    /// refunded on the same dimensions before returning. Both budgets
171    /// must use identical `BudgetSpec` shapes so the refund is exact.
172    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        // Gate 1: parse.
184        let req = match parse_tool_call(raw) {
185            Ok(r) => r,
186            Err(e) => {
187                let (stage, reason, denial_code) = match &e {
188                    // Passthrough refusal parsed fine as JSON-RPC — a
189                    // deliberate policy choice, distinct HTTP class
190                    // (403) from true protocol failures (400).
191                    RpcError::NotToolCall(_) => (
192                        "passthrough",
193                        format!("passthrough refused: {e}"),
194                        DenialCode::PassthroughRefused,
195                    ),
196                    _ => ("parse", e.to_string(), DenialCode::ParseError),
197                };
198                // Protocol-level failures
199                // get the reserved JSON-RPC codes (-32700/-32600/
200                // -32602), not the -32001 policy code — the request
201                // never reached an authorization decision. The id is
202                // echoed whenever the envelope made it detectable
203                // (JSON-RPC 2.0 §5; see `detectable_error_id`).
204                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        // Gate 2: schema.
218        if let Some(validator) = self.validators.get(&req.tool) {
219            // Bounded work: a hostile client cannot force unbounded String
220            // allocation by sending pathologically-invalid arguments.
221            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        // Gate 2b: console-pushed schemas, on top of the local one.
252        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        // Gate 3: policy chain (first deny wins).
273        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        // Gate 3b: console-pushed allowlists, after the local chain.
289        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        // Gate 4: budget (atomic check-and-spend).
305        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        // Principal-scoped pre-check. Debit the principal ledger
322        // first — a header-rotation attack that resets the session bucket
323        // still charges the same principal. If the principal ledger admits
324        // the spend but the session ledger refuses, we refund the principal
325        // debit before returning so the failed call consumed no quota
326        // anywhere.
327        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                    // Session refused; unwind the principal debit so a
368                    // failed call is fully non-consumptive.
369                    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                // Thread the actual debited amount so the
406                // harness can refund exactly this much on a lost
407                // execution.claim() race.
408                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                // State-store failure fails closed.
429                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    /// Evaluate an arbitrary operation payload against the configured native
444    /// and WASM policy chain. Used for chat sanitization before compression.
445    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
455/// Extract a payout amount (USD float or integer) as micro-USD. Rejects
456/// negatives, NaN/Inf, and absurd magnitudes rather than truncating silently.
457fn 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    // Ceil, not round: a positive sub-micro amount (e.g. 0.0000004 USD)
469    // rounded to ZERO micros, and a zero spend skips payout enforcement
470    // entirely (`ActionBudget` only gates payout > 0) — including the
471    // fail-closed "payout with NO cap configured is refused" rule. A
472    // hostile tool argument could drip unaccounted positive payouts
473    // forever. Any positive amount now costs at least one micro-USD,
474    // so the cap (or its absence) always gets a say; exact zero stays
475    // zero.
476    #[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        // Before any bundle: the local config alone decides.
546        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        // The local schema still applies on top of nothing pushed for db_write.
601        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        // Third allowed call above was the cap; the fourth hits the pushed budget.
610        let over = s.check(&store, "sess-q", &raw_call("search", json!({})));
611        assert_eq!(blocked_policy(&over), (Some("finance.calls".into()), "budget"));
612
613        // A new bundle replaces the old rules entirely.
614        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        // R23: block/allow decision < 5ms = 5000µs. A single debug-profile
632        // sample flakes under full-suite load (scheduler preemption between
633        // the two Instant reads), so take the minimum over several runs:
634        // one clean time slice is enough for a genuinely fast decision,
635        // while a real regression stays slow on every sample. Distinct
636        // sessions keep the per-session db_write budget (3) out of play.
637        // The release SLA gate and criterion bench own the authoritative
638        // measurement.
639        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        // Missing required field.
659        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        // Type confusion.
668        let v = s.check(
669            &store,
670            "s",
671            &raw_call("db_write", json!({"table": 42, "row": {}})),
672        );
673        assert!(!v.is_allowed());
674        // Extra field (additionalProperties: false).
675        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                // Named-policy denials carry the engine name so the
697                // console can attribute the block to a policy row.
698                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        // Parse gate: invalid JSON-RPC.
708        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    /// JSON-RPC 2.0 §5: parse-gate refusals of a WELL-FORMED envelope
719    /// must echo the request id in the error response. Pre-fix the
720    /// blocked-response body always carried `id: null`, so SDK
721    /// correlation tables never matched and the pending call hung.
722    #[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                // Mutation-run hardening (round 8): the passthrough
735                // stage label was unpinned — deleting the NotToolCall
736                // match arm reclassified passthrough refusals as
737                // "parse" failures, corrupting the stage-keyed metrics
738                // and events operators alert on.
739                assert_eq!(stage, "passthrough");
740                assert!(reason.contains("passthrough refused"), "{reason}");
741            }
742            other => panic!("{other:?}"),
743        }
744        // Unparseable JSON: the id is undetectable — null stands.
745        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    /// An `Allowed` verdict must thread the bound principal id out so the
775    /// harness's failure paths (lost claim race, saturated worker, close
776    /// race) can mirror the session-ledger refund on the principal ledger.
777    /// Without it, every failed claim permanently consumed principal quota
778    /// with zero tools executed — and the principal ledger persists across
779    /// sessions, so that leak never healed. The refund round-trip below is
780    /// exactly the compensation the harness performs.
781    #[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        // No principal bound: field is None.
793        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        // Principal bound: the verdict carries the id and payout the
798        // harness needs for compensation.
799        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        // Simulate the harness failure path: refund BOTH ledgers with the
817        // threaded values. With max_total_tool_calls = 1, the next call
818        // only admits if the principal refund actually landed.
819        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        // Would cross $50 total.
842        let v = s.check(&store, "p", &raw_call("payout", json!({"amount_usd": 1.0})));
843        assert!(!v.is_allowed(), "{v:?}");
844        // Hostile numerics.
845        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    /// Mutation-run hardening: pin the payout parser's exact
935    /// bounds. `usd < 0.0` -> `<=` would refuse a legitimate zero payout;
936    /// `usd > 1.0e12` -> `>=`/`==` shift or invert the sanity ceiling.
937    #[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        // Sub-micro positive amounts must CEIL to one micro, never round
954        // to zero: a zero result skips payout enforcement entirely —
955        // including the fail-closed no-cap refusal — so a hostile tool
956        // argument of 0.0000004 USD per call could drip unaccounted
957        // positive payouts forever.
958        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}