Skip to main content

fd_policy/
engine.rs

1//! Policy engine implementation
2
3use crate::bench_audit::{BenchAuditPolicy, BenchGatedClaim, BenchTrustSummary};
4use crate::budget::{Budget, BudgetUsage};
5use crate::decision::PolicyDecision;
6use crate::precedence::{resolve_conflicts, PolicyVerdict, VerdictKind};
7use crate::rules::ToolAllowlist;
8use crate::trace::DecisionTrace;
9use tracing::instrument;
10
11/// The policy engine evaluates actions against configured rules
12#[derive(Default)]
13pub struct PolicyEngine {
14    tool_allowlist: ToolAllowlist,
15    default_budget: Budget,
16}
17
18impl PolicyEngine {
19    pub fn new(tool_allowlist: ToolAllowlist, default_budget: Budget) -> Self {
20        Self {
21            tool_allowlist,
22            default_budget,
23        }
24    }
25
26    /// Evaluate whether a tool call is allowed.
27    ///
28    /// Now gathers *every* matching verdict from the allowlist and runs
29    /// them through [`crate::precedence::resolve_conflicts`], so two
30    /// disagreeing policies produce a deterministic winner plus a full
31    /// explanation trace surfaced on [`PolicyDecision::trace`]. Behaviour
32    /// matches the legacy short-circuit (`Deny > RequiresApproval >
33    /// Allow > default-deny`) — back-compat is covered by existing tests.
34    #[instrument(skip(self))]
35    pub fn evaluate_tool_call(&self, tool_name: &str) -> PolicyDecision {
36        self.evaluate_tool_call_with(&self.tool_allowlist, tool_name)
37    }
38
39    /// Evaluate a tool call against a *caller-supplied* allowlist instead of
40    /// the engine's process-global default.
41    ///
42    /// The gateway uses this to hold each run to **its own agent's** configured
43    /// tools (deny / approval-required / allowed tiers), so deny-by-default is
44    /// keyed on the agent — not on a single empty default shared across every
45    /// run. [`Self::evaluate_tool_call`] is the special case where the allowlist
46    /// is the engine's own default.
47    #[instrument(skip(self, allowlist))]
48    pub fn evaluate_tool_call_with(
49        &self,
50        allowlist: &ToolAllowlist,
51        tool_name: &str,
52    ) -> PolicyDecision {
53        let matched = allowlist.matches(tool_name);
54        let resolved = resolve_conflicts(matched.clone());
55        let trace = DecisionTrace::from_resolution(matched, &resolved);
56
57        let decision = match resolved.winning.as_ref().map(|v| v.kind) {
58            Some(VerdictKind::Deny) => {
59                let reason = resolved
60                    .winning
61                    .as_ref()
62                    .map(|v| v.reason.clone())
63                    .unwrap_or_else(|| format!("tool '{}' is denied", tool_name));
64                PolicyDecision::deny(reason)
65            }
66            Some(VerdictKind::RequiresApproval) => {
67                let reason = resolved
68                    .winning
69                    .as_ref()
70                    .map(|v| v.reason.clone())
71                    .unwrap_or_else(|| format!("tool '{}' requires approval", tool_name));
72                PolicyDecision::requires_approval(reason)
73            }
74            Some(VerdictKind::Allow) => {
75                let reason = resolved
76                    .winning
77                    .as_ref()
78                    .map(|v| v.reason.clone())
79                    .unwrap_or_else(|| format!("tool '{}' is in allowlist", tool_name));
80                PolicyDecision::allow(reason)
81            }
82            // BudgetCap should never be a winner from allowlist matching —
83            // the budget plane is checked via `check_budget`. Treat it as
84            // a deny if it ever appears here (defence-in-depth).
85            Some(VerdictKind::BudgetCap) => {
86                PolicyDecision::deny(format!("tool '{}' blocked by budget cap", tool_name))
87            }
88            // No matches at all → deny-by-default. The trace still records
89            // the empty match set so audit can prove the allowlist saw
90            // nothing for this tool.
91            None => PolicyDecision::deny(format!("tool '{}' is not in allowlist", tool_name)),
92        };
93
94        decision.with_trace(trace)
95    }
96
97    /// Check if budget allows continuing. The returned decision carries a
98    /// trace whose single matched verdict (if any) is the offending
99    /// budget axis.
100    #[instrument(skip(self))]
101    pub fn check_budget(&self, usage: &BudgetUsage, budget: Option<&Budget>) -> PolicyDecision {
102        let budget = budget.unwrap_or(&self.default_budget);
103
104        let (decision, matched) = match usage.check_against(budget) {
105            Some(exceeded) => {
106                let verdict = PolicyVerdict::new(
107                    VerdictKind::BudgetCap,
108                    format!("budget:{}", budget_axis(&exceeded)),
109                    format!("budget exceeded: {}", exceeded),
110                );
111                let matched = vec![verdict];
112                (
113                    PolicyDecision::deny(format!("budget exceeded: {}", exceeded)),
114                    matched,
115                )
116            }
117            None => (PolicyDecision::allow("within budget limits"), Vec::new()),
118        };
119
120        let resolved = resolve_conflicts(matched.clone());
121        let trace = DecisionTrace::from_resolution(matched, &resolved);
122        decision.with_trace(trace)
123    }
124
125    /// Get the default budget
126    pub fn default_budget(&self) -> &Budget {
127        &self.default_budget
128    }
129
130    /// Evaluate a routing / model-swap decision that cites an external
131    /// benchmark delta. Reuses [`resolve_conflicts`] so the bench-audit gate
132    /// shares precedence semantics with the allowlist + budget tiers — this
133    /// is a new *rule source*, not a parallel engine. Deny-by-default still
134    /// applies: if the [`BenchAuditPolicy`] emits no verdicts (no signal
135    /// from the eval plane), the decision is denied with an empty trace.
136    #[instrument(skip(self, policy, claim, summary))]
137    pub fn evaluate_bench_gated_decision(
138        &self,
139        policy: &BenchAuditPolicy,
140        claim: &BenchGatedClaim,
141        summary: &BenchTrustSummary,
142    ) -> PolicyDecision {
143        let matched = policy.evaluate(claim, summary);
144        let resolved = resolve_conflicts(matched.clone());
145        let trace = DecisionTrace::from_resolution(matched, &resolved);
146
147        let decision = match resolved.winning.as_ref().map(|v| v.kind) {
148            Some(VerdictKind::Deny) => PolicyDecision::deny(
149                resolved
150                    .winning
151                    .as_ref()
152                    .map(|v| v.reason.clone())
153                    .unwrap_or_else(|| {
154                        format!("bench-audit gate denied decision '{}'", claim.decision_id)
155                    }),
156            ),
157            Some(VerdictKind::RequiresApproval) => PolicyDecision::requires_approval(
158                resolved
159                    .winning
160                    .as_ref()
161                    .map(|v| v.reason.clone())
162                    .unwrap_or_else(|| {
163                        format!(
164                            "bench-audit gate requires approval for decision '{}'",
165                            claim.decision_id
166                        )
167                    }),
168            ),
169            Some(VerdictKind::Allow) => PolicyDecision::allow(
170                resolved
171                    .winning
172                    .as_ref()
173                    .map(|v| v.reason.clone())
174                    .unwrap_or_else(|| {
175                        format!("bench-audit gate allowed decision '{}'", claim.decision_id)
176                    }),
177            ),
178            // `BudgetCap` is a budget-plane signal — it should never come out
179            // of the bench-audit policy. Defence-in-depth: treat as deny.
180            Some(VerdictKind::BudgetCap) => PolicyDecision::deny(format!(
181                "decision '{}' rejected by spurious budget verdict from bench-audit plane",
182                claim.decision_id
183            )),
184            // No matches → deny-by-default. The trace still records the empty
185            // match set so audit can prove the policy plane saw nothing.
186            None => PolicyDecision::deny(format!(
187                "bench-audit plane returned no signal for decision '{}' — denying by default",
188                claim.decision_id
189            )),
190        };
191
192        decision.with_trace(trace)
193    }
194}
195
196/// Stable axis label for a [`BudgetExceeded`](crate::budget::BudgetExceeded)
197/// — used as the `budget:<axis>` source string on the trace.
198fn budget_axis(exceeded: &crate::budget::BudgetExceeded) -> &'static str {
199    use crate::budget::BudgetExceeded;
200    match exceeded {
201        BudgetExceeded::InputTokens { .. } => "max_input_tokens",
202        BudgetExceeded::OutputTokens { .. } => "max_output_tokens",
203        BudgetExceeded::TotalTokens { .. } => "max_total_tokens",
204        BudgetExceeded::ToolCalls { .. } => "max_tool_calls",
205        BudgetExceeded::WallTime { .. } => "max_wall_time_ms",
206        BudgetExceeded::Cost { .. } => "max_cost_cents",
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    // =============================================================================
215    // Tool Allowlist Tests
216    // =============================================================================
217
218    #[test]
219    fn test_tool_allowlist_deny_by_default() {
220        let engine = PolicyEngine::default();
221        let decision = engine.evaluate_tool_call("unknown_tool");
222        assert!(decision.is_denied());
223        assert!(decision.reason.contains("not in allowlist"));
224    }
225
226    #[test]
227    fn test_tool_allowlist_allow() {
228        let allowlist = ToolAllowlist {
229            allowed_tools: vec!["read_file".to_string()],
230            ..Default::default()
231        };
232        let engine = PolicyEngine::new(allowlist, Budget::default());
233        let decision = engine.evaluate_tool_call("read_file");
234        assert!(decision.is_allowed());
235    }
236
237    #[test]
238    fn test_tool_allowlist_requires_approval() {
239        let allowlist = ToolAllowlist {
240            allowed_tools: vec![],
241            approval_required: vec!["write_file".to_string()],
242            denied_tools: vec![],
243        };
244        let engine = PolicyEngine::new(allowlist, Budget::default());
245        let decision = engine.evaluate_tool_call("write_file");
246        assert!(decision.needs_approval());
247        assert!(decision.reason.contains("requires approval"));
248    }
249
250    #[test]
251    fn test_tool_explicit_deny_takes_precedence() {
252        let allowlist = ToolAllowlist {
253            allowed_tools: vec!["dangerous_tool".to_string()], // Also in allowed
254            approval_required: vec![],
255            denied_tools: vec!["dangerous_tool".to_string()], // But explicitly denied
256        };
257        let engine = PolicyEngine::new(allowlist, Budget::default());
258        let decision = engine.evaluate_tool_call("dangerous_tool");
259        // Explicit deny should take precedence over allowed
260        assert!(decision.is_denied());
261    }
262
263    #[test]
264    fn test_tool_allowlist_multiple_tools() {
265        let allowlist = ToolAllowlist {
266            allowed_tools: vec![
267                "read_file".to_string(),
268                "list_directory".to_string(),
269                "get_time".to_string(),
270            ],
271            approval_required: vec!["write_file".to_string(), "delete_file".to_string()],
272            denied_tools: vec!["exec_shell".to_string()],
273        };
274        let engine = PolicyEngine::new(allowlist, Budget::default());
275
276        // Allowed tools
277        assert!(engine.evaluate_tool_call("read_file").is_allowed());
278        assert!(engine.evaluate_tool_call("list_directory").is_allowed());
279        assert!(engine.evaluate_tool_call("get_time").is_allowed());
280
281        // Approval required
282        assert!(engine.evaluate_tool_call("write_file").needs_approval());
283        assert!(engine.evaluate_tool_call("delete_file").needs_approval());
284
285        // Denied
286        assert!(engine.evaluate_tool_call("exec_shell").is_denied());
287        assert!(engine.evaluate_tool_call("unknown").is_denied());
288    }
289
290    // =============================================================================
291    // Budget Tests
292    // =============================================================================
293
294    #[test]
295    fn test_budget_exceeded() {
296        let engine = PolicyEngine::default();
297        let usage = BudgetUsage {
298            input_tokens: 200_000, // Over default limit of 100_000
299            ..Default::default()
300        };
301        let decision = engine.check_budget(&usage, None);
302        assert!(decision.is_denied());
303        assert!(decision.reason.contains("budget exceeded"));
304    }
305
306    #[test]
307    fn test_budget_within_limits() {
308        let engine = PolicyEngine::default();
309        let usage = BudgetUsage {
310            input_tokens: 50_000,
311            output_tokens: 25_000,
312            tool_calls: 10,
313            wall_time_ms: 60_000,
314            cost_cents: 100,
315        };
316        let decision = engine.check_budget(&usage, None);
317        assert!(decision.is_allowed());
318    }
319
320    #[test]
321    fn test_budget_output_tokens_exceeded() {
322        let engine = PolicyEngine::default();
323        let usage = BudgetUsage {
324            input_tokens: 10_000,
325            output_tokens: 100_000, // Over default limit of 50_000
326            ..Default::default()
327        };
328        let decision = engine.check_budget(&usage, None);
329        assert!(decision.is_denied());
330        assert!(decision.reason.contains("output tokens"));
331    }
332
333    #[test]
334    fn test_budget_total_tokens_exceeded() {
335        // Use a custom budget with only total tokens limit to test specifically
336        let budget = Budget {
337            max_input_tokens: None,
338            max_output_tokens: None,
339            max_total_tokens: Some(150_000),
340            max_tool_calls: None,
341            max_wall_time_ms: None,
342            max_cost_cents: None,
343        };
344        let engine = PolicyEngine::new(ToolAllowlist::default(), budget);
345        let usage = BudgetUsage {
346            input_tokens: 80_000,
347            output_tokens: 80_000, // Total 160,000 > limit of 150,000
348            ..Default::default()
349        };
350        let decision = engine.check_budget(&usage, None);
351        assert!(decision.is_denied());
352        assert!(decision.reason.contains("total tokens"));
353    }
354
355    #[test]
356    fn test_budget_tool_calls_exceeded() {
357        let engine = PolicyEngine::default();
358        let usage = BudgetUsage {
359            tool_calls: 100, // Over default limit of 50
360            ..Default::default()
361        };
362        let decision = engine.check_budget(&usage, None);
363        assert!(decision.is_denied());
364        assert!(decision.reason.contains("tool calls"));
365    }
366
367    #[test]
368    fn test_budget_wall_time_exceeded() {
369        let engine = PolicyEngine::default();
370        let usage = BudgetUsage {
371            wall_time_ms: 10 * 60 * 1000, // 10 minutes > 5 minute limit
372            ..Default::default()
373        };
374        let decision = engine.check_budget(&usage, None);
375        assert!(decision.is_denied());
376        assert!(decision.reason.contains("wall time"));
377    }
378
379    #[test]
380    fn test_budget_cost_exceeded() {
381        let engine = PolicyEngine::default();
382        let usage = BudgetUsage {
383            cost_cents: 1000, // $10 > $5 limit
384            ..Default::default()
385        };
386        let decision = engine.check_budget(&usage, None);
387        assert!(decision.is_denied());
388        assert!(decision.reason.contains("cost"));
389    }
390
391    #[test]
392    fn test_custom_budget_override() {
393        let engine = PolicyEngine::default();
394
395        let usage = BudgetUsage {
396            input_tokens: 500_000, // Would exceed default input limit of 100k
397            ..Default::default()
398        };
399
400        // Custom budget with higher limits for all token-related metrics
401        let custom_budget = Budget {
402            max_input_tokens: Some(1_000_000),
403            max_output_tokens: Some(1_000_000),
404            max_total_tokens: Some(2_000_000),
405            max_tool_calls: Some(100),
406            max_wall_time_ms: Some(10 * 60 * 1000),
407            max_cost_cents: Some(1000),
408        };
409
410        let decision = engine.check_budget(&usage, Some(&custom_budget));
411        assert!(decision.is_allowed()); // Custom budget allows it
412    }
413
414    #[test]
415    fn test_budget_no_limits() {
416        // Create budget with no limits
417        let budget = Budget {
418            max_input_tokens: None,
419            max_output_tokens: None,
420            max_total_tokens: None,
421            max_tool_calls: None,
422            max_wall_time_ms: None,
423            max_cost_cents: None,
424        };
425        let engine = PolicyEngine::new(ToolAllowlist::default(), budget);
426
427        let usage = BudgetUsage {
428            input_tokens: 1_000_000,
429            output_tokens: 1_000_000,
430            tool_calls: 1000,
431            wall_time_ms: 1_000_000,
432            cost_cents: 100_000,
433        };
434
435        let decision = engine.check_budget(&usage, None);
436        assert!(decision.is_allowed()); // No limits means always allowed
437    }
438
439    // =============================================================================
440    // Policy Decision Tests
441    // =============================================================================
442
443    #[test]
444    fn test_policy_decision_ids_are_unique() {
445        let engine = PolicyEngine::default();
446
447        let decision1 = engine.evaluate_tool_call("tool1");
448        let decision2 = engine.evaluate_tool_call("tool2");
449
450        assert_ne!(decision1.id, decision2.id);
451    }
452
453    #[test]
454    fn test_policy_decision_has_meaningful_reason() {
455        let allowlist = ToolAllowlist {
456            allowed_tools: vec!["allowed_tool".to_string()],
457            approval_required: vec!["approval_tool".to_string()],
458            denied_tools: vec![],
459        };
460        let engine = PolicyEngine::new(allowlist, Budget::default());
461
462        let allow_decision = engine.evaluate_tool_call("allowed_tool");
463        assert!(allow_decision.reason.contains("allowed_tool"));
464
465        let approval_decision = engine.evaluate_tool_call("approval_tool");
466        assert!(approval_decision.reason.contains("approval_tool"));
467
468        let deny_decision = engine.evaluate_tool_call("unknown_tool");
469        assert!(deny_decision.reason.contains("unknown_tool"));
470    }
471
472    // =============================================================================
473    // Integration Scenarios
474    // =============================================================================
475
476    // =============================================================================
477    // Conflict-resolution + trace integration tests
478    // =============================================================================
479
480    #[test]
481    fn conflict_deny_overrides_allow_records_trace() {
482        // Tool is on BOTH the allow- and deny-lists. Deny must win, and
483        // the trace must record the Allow as overridden.
484        let allowlist = ToolAllowlist {
485            allowed_tools: vec!["dangerous_tool".into()],
486            approval_required: vec![],
487            denied_tools: vec!["dangerous_tool".into()],
488        };
489        let engine = PolicyEngine::new(allowlist, Budget::default());
490        let decision = engine.evaluate_tool_call("dangerous_tool");
491
492        assert!(decision.is_denied());
493        let trace = decision.trace.as_ref().expect("trace populated");
494        assert_eq!(trace.matched.len(), 2);
495        assert_eq!(trace.winning_kind, Some(VerdictKind::Deny));
496        assert_eq!(trace.winning_source.as_deref(), Some("allowlist:denied"));
497        assert_eq!(trace.overrides.len(), 1);
498        assert_eq!(trace.overrides[0].verdict.kind, VerdictKind::Allow);
499        assert!(trace.overrides[0].reason.contains("higher-precedence deny"));
500    }
501
502    #[test]
503    fn conflict_approval_overrides_allow_records_trace() {
504        let allowlist = ToolAllowlist {
505            allowed_tools: vec!["write_file".into()],
506            approval_required: vec!["write_file".into()],
507            denied_tools: vec![],
508        };
509        let engine = PolicyEngine::new(allowlist, Budget::default());
510        let decision = engine.evaluate_tool_call("write_file");
511
512        assert!(decision.needs_approval());
513        let trace = decision.trace.as_ref().expect("trace populated");
514        assert_eq!(trace.winning_kind, Some(VerdictKind::RequiresApproval));
515        assert_eq!(trace.overrides.len(), 1);
516        assert_eq!(trace.overrides[0].verdict.kind, VerdictKind::Allow);
517    }
518
519    #[test]
520    fn conflict_three_way_deny_wins_and_records_two_overrides() {
521        let allowlist = ToolAllowlist {
522            allowed_tools: vec!["multi_tool".into()],
523            approval_required: vec!["multi_tool".into()],
524            denied_tools: vec!["multi_tool".into()],
525        };
526        let engine = PolicyEngine::new(allowlist, Budget::default());
527        let decision = engine.evaluate_tool_call("multi_tool");
528
529        assert!(decision.is_denied());
530        let trace = decision.trace.as_ref().expect("trace populated");
531        assert_eq!(trace.matched.len(), 3);
532        assert_eq!(trace.winning_kind, Some(VerdictKind::Deny));
533        assert_eq!(trace.overrides.len(), 2);
534        // Overrides in submission order: approval then allow.
535        assert_eq!(
536            trace.overrides[0].verdict.kind,
537            VerdictKind::RequiresApproval
538        );
539        assert_eq!(trace.overrides[1].verdict.kind, VerdictKind::Allow);
540        assert!(trace.had_conflicts());
541    }
542
543    #[test]
544    fn no_conflict_allow_only_records_clean_trace() {
545        let allowlist = ToolAllowlist {
546            allowed_tools: vec!["read_file".into()],
547            approval_required: vec![],
548            denied_tools: vec![],
549        };
550        let engine = PolicyEngine::new(allowlist, Budget::default());
551        let decision = engine.evaluate_tool_call("read_file");
552
553        assert!(decision.is_allowed());
554        let trace = decision.trace.as_ref().expect("trace populated");
555        assert_eq!(trace.matched.len(), 1);
556        assert_eq!(trace.winning_kind, Some(VerdictKind::Allow));
557        assert!(trace.overrides.is_empty());
558        assert!(!trace.had_conflicts());
559    }
560
561    #[test]
562    fn default_deny_records_empty_matched_set() {
563        // Tool not on any list — deny by default, trace records the empty
564        // match set so audit can prove the allowlist saw nothing.
565        let engine = PolicyEngine::default();
566        let decision = engine.evaluate_tool_call("unknown_tool");
567
568        assert!(decision.is_denied());
569        let trace = decision.trace.as_ref().expect("trace populated");
570        assert!(trace.matched.is_empty());
571        assert!(trace.winning_kind.is_none());
572        assert!(trace.overrides.is_empty());
573    }
574
575    #[test]
576    fn budget_check_attaches_budget_cap_verdict_in_trace() {
577        let engine = PolicyEngine::default();
578        let usage = BudgetUsage {
579            cost_cents: 10_000, // way over default $5 limit
580            ..Default::default()
581        };
582        let decision = engine.check_budget(&usage, None);
583
584        assert!(decision.is_denied());
585        let trace = decision.trace.as_ref().expect("trace populated");
586        assert_eq!(trace.matched.len(), 1);
587        assert_eq!(trace.matched[0].kind, VerdictKind::BudgetCap);
588        assert_eq!(trace.matched[0].source, "budget:max_cost_cents");
589        assert_eq!(trace.winning_kind, Some(VerdictKind::BudgetCap));
590    }
591
592    #[test]
593    fn budget_within_limits_records_empty_trace() {
594        let engine = PolicyEngine::default();
595        let usage = BudgetUsage::default();
596        let decision = engine.check_budget(&usage, None);
597
598        assert!(decision.is_allowed());
599        let trace = decision.trace.as_ref().expect("trace populated");
600        assert!(trace.matched.is_empty());
601        assert!(trace.winning_kind.is_none());
602    }
603
604    // =============================================================================
605    // Bench-audit gate (arXiv:2605.26079) — wires the new policy through the
606    // existing precedence resolver + decision trace, not a parallel engine.
607    // =============================================================================
608
609    use crate::bench_audit::{BenchAuditPolicy, BenchGatedClaim, BenchTrustSummary};
610    use chrono::DateTime;
611
612    fn audit_summary(score: f64, flagged: u32, total: u32) -> BenchTrustSummary {
613        BenchTrustSummary {
614            suite_id: "smoke".into(),
615            bench_trust_score: score,
616            total_tasks: total,
617            flagged_task_count: flagged,
618            audited_at: DateTime::from_timestamp(1_700_000_000, 0).expect("ts"),
619            anchor: crate::bench_audit::BENCH_AUDIT_ANCHOR.into(),
620        }
621    }
622
623    fn audit_claim(delta: f64) -> BenchGatedClaim {
624        BenchGatedClaim {
625            decision_id: "dec_routing".into(),
626            suite_id: "smoke".into(),
627            delta_score: delta,
628        }
629    }
630
631    #[test]
632    fn bench_gate_clean_suite_allows_and_records_trace() {
633        let engine = PolicyEngine::default();
634        let decision = engine.evaluate_bench_gated_decision(
635            &BenchAuditPolicy::default(),
636            &audit_claim(0.05),
637            &audit_summary(0.92, 0, 20),
638        );
639
640        assert!(
641            decision.is_allowed(),
642            "clean suite + clear delta must allow"
643        );
644        let trace = decision.trace.as_ref().expect("trace populated");
645        assert_eq!(trace.winning_kind, Some(VerdictKind::Allow));
646        assert_eq!(
647            trace.winning_source.as_deref(),
648            Some("bench_audit:high_trust_score"),
649        );
650    }
651
652    #[test]
653    fn bench_gate_low_trust_denies_with_trace_source() {
654        let engine = PolicyEngine::default();
655        let decision = engine.evaluate_bench_gated_decision(
656            &BenchAuditPolicy::default(),
657            &audit_claim(0.20),
658            &audit_summary(0.40, 12, 20),
659        );
660
661        assert!(decision.is_denied());
662        let trace = decision.trace.as_ref().expect("trace populated");
663        assert_eq!(trace.winning_kind, Some(VerdictKind::Deny));
664        let source = trace.winning_source.as_deref().unwrap_or("");
665        assert!(
666            source == "bench_audit:low_trust_score"
667                || source == "bench_audit:within_flagged_margin",
668            "expected a bench_audit:* deny source, got {source:?}"
669        );
670    }
671
672    #[test]
673    fn bench_gate_within_flagged_margin_records_override() {
674        // 5/20 flagged → 0.25 margin. A 0.10 delta sits inside it even though
675        // the suite is otherwise clean. Engine must still deny *and* record
676        // that there was a conflict in the trace (the high-trust Allow gets
677        // overridden by the within-margin Deny).
678        let engine = PolicyEngine::default();
679        let decision = engine.evaluate_bench_gated_decision(
680            &BenchAuditPolicy::default(),
681            &audit_claim(0.10),
682            &audit_summary(0.92, 5, 20),
683        );
684
685        assert!(decision.is_denied());
686        let trace = decision.trace.as_ref().expect("trace populated");
687        assert_eq!(
688            trace.winning_source.as_deref(),
689            Some("bench_audit:within_flagged_margin"),
690        );
691        // The Allow verdict must be recorded as overridden, not silently
692        // dropped — that's the contract DecisionTrace owes audit consumers.
693        assert!(
694            trace
695                .overrides
696                .iter()
697                .any(|o| o.verdict.source == "bench_audit:high_trust_score"
698                    && o.overridden_by == VerdictKind::Deny),
699            "expected high_trust_score Allow to be recorded as overridden: {trace:?}",
700        );
701    }
702
703    #[test]
704    fn bench_gate_mid_band_requires_approval() {
705        let engine = PolicyEngine::default();
706        let decision = engine.evaluate_bench_gated_decision(
707            &BenchAuditPolicy::default(),
708            &audit_claim(0.10),
709            &audit_summary(0.75, 0, 20),
710        );
711
712        assert!(decision.needs_approval());
713        let trace = decision.trace.as_ref().expect("trace populated");
714        assert_eq!(trace.winning_kind, Some(VerdictKind::RequiresApproval));
715        assert_eq!(
716            trace.winning_source.as_deref(),
717            Some("bench_audit:hitl_band"),
718        );
719    }
720
721    #[test]
722    fn bench_gate_suite_mismatch_denies() {
723        let engine = PolicyEngine::default();
724        let mismatched = BenchGatedClaim {
725            decision_id: "dec_001".into(),
726            suite_id: "regression".into(),
727            delta_score: 0.30,
728        };
729        let decision = engine.evaluate_bench_gated_decision(
730            &BenchAuditPolicy::default(),
731            &mismatched,
732            &audit_summary(0.92, 0, 20),
733        );
734
735        assert!(decision.is_denied());
736        let trace = decision.trace.as_ref().expect("trace populated");
737        assert_eq!(
738            trace.winning_source.as_deref(),
739            Some("bench_audit:suite_mismatch"),
740        );
741    }
742
743    #[test]
744    fn trace_precedence_label_matches_canonical_string() {
745        let engine = PolicyEngine::default();
746        let decision = engine.evaluate_tool_call("anything");
747        let trace = decision.trace.as_ref().expect("trace populated");
748        assert_eq!(trace.precedence, crate::precedence::PRECEDENCE_LABEL);
749    }
750
751    #[test]
752    fn test_realistic_agent_policy() {
753        // Simulate a realistic agent configuration
754        let allowlist = ToolAllowlist {
755            allowed_tools: vec![
756                "read_file".to_string(),
757                "list_directory".to_string(),
758                "search_code".to_string(),
759                "get_current_time".to_string(),
760            ],
761            approval_required: vec![
762                "write_file".to_string(),
763                "create_file".to_string(),
764                "execute_command".to_string(),
765            ],
766            denied_tools: vec![
767                "delete_production_data".to_string(),
768                "access_secrets".to_string(),
769            ],
770        };
771
772        let budget = Budget {
773            max_input_tokens: Some(50_000),
774            max_output_tokens: Some(25_000),
775            max_total_tokens: Some(75_000),
776            max_tool_calls: Some(20),
777            max_wall_time_ms: Some(2 * 60 * 1000), // 2 minutes
778            max_cost_cents: Some(100),             // $1
779        };
780
781        let engine = PolicyEngine::new(allowlist, budget);
782
783        // Safe read operations should be allowed
784        assert!(engine.evaluate_tool_call("read_file").is_allowed());
785        assert!(engine.evaluate_tool_call("search_code").is_allowed());
786
787        // Write operations need approval
788        assert!(engine.evaluate_tool_call("write_file").needs_approval());
789
790        // Dangerous operations are denied
791        assert!(engine
792            .evaluate_tool_call("delete_production_data")
793            .is_denied());
794
795        // Unknown tools are denied by default
796        assert!(engine.evaluate_tool_call("curl").is_denied());
797
798        // Check budget enforcement
799        let light_usage = BudgetUsage {
800            input_tokens: 10_000,
801            output_tokens: 5_000,
802            tool_calls: 5,
803            wall_time_ms: 30_000,
804            cost_cents: 25,
805        };
806        assert!(engine.check_budget(&light_usage, None).is_allowed());
807
808        let heavy_usage = BudgetUsage {
809            input_tokens: 100_000, // Over limit
810            output_tokens: 5_000,
811            tool_calls: 5,
812            wall_time_ms: 30_000,
813            cost_cents: 25,
814        };
815        assert!(engine.check_budget(&heavy_usage, None).is_denied());
816    }
817}