1use 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#[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 #[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 #[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 Some(VerdictKind::BudgetCap) => {
86 PolicyDecision::deny(format!("tool '{}' blocked by budget cap", tool_name))
87 }
88 None => PolicyDecision::deny(format!("tool '{}' is not in allowlist", tool_name)),
92 };
93
94 decision.with_trace(trace)
95 }
96
97 #[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 pub fn default_budget(&self) -> &Budget {
127 &self.default_budget
128 }
129
130 #[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 Some(VerdictKind::BudgetCap) => PolicyDecision::deny(format!(
181 "decision '{}' rejected by spurious budget verdict from bench-audit plane",
182 claim.decision_id
183 )),
184 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
196fn 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 #[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()], approval_required: vec![],
255 denied_tools: vec!["dangerous_tool".to_string()], };
257 let engine = PolicyEngine::new(allowlist, Budget::default());
258 let decision = engine.evaluate_tool_call("dangerous_tool");
259 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 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 assert!(engine.evaluate_tool_call("write_file").needs_approval());
283 assert!(engine.evaluate_tool_call("delete_file").needs_approval());
284
285 assert!(engine.evaluate_tool_call("exec_shell").is_denied());
287 assert!(engine.evaluate_tool_call("unknown").is_denied());
288 }
289
290 #[test]
295 fn test_budget_exceeded() {
296 let engine = PolicyEngine::default();
297 let usage = BudgetUsage {
298 input_tokens: 200_000, ..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, ..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 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, ..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, ..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, ..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, ..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, ..Default::default()
398 };
399
400 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()); }
413
414 #[test]
415 fn test_budget_no_limits() {
416 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()); }
438
439 #[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 #[test]
481 fn conflict_deny_overrides_allow_records_trace() {
482 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 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 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, ..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 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 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 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 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), max_cost_cents: Some(100), };
780
781 let engine = PolicyEngine::new(allowlist, budget);
782
783 assert!(engine.evaluate_tool_call("read_file").is_allowed());
785 assert!(engine.evaluate_tool_call("search_code").is_allowed());
786
787 assert!(engine.evaluate_tool_call("write_file").needs_approval());
789
790 assert!(engine
792 .evaluate_tool_call("delete_production_data")
793 .is_denied());
794
795 assert!(engine.evaluate_tool_call("curl").is_denied());
797
798 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, 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}