1use std::sync::Arc;
16
17use parking_lot::RwLock;
18use tracing::debug;
19
20use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
21use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
22use crate::policy::{PolicyContext, PolicyDecision, PolicyEnforcer};
23use crate::registry::ToolDef;
24
25pub type TrajectoryRiskSlot = Arc<parking_lot::RwLock<u8>>;
32
33pub type RiskSignalSink = Arc<dyn Fn(u8) + Send + Sync>;
38
39pub type RiskSignalQueue = Arc<parking_lot::Mutex<Vec<u8>>>;
45
46pub struct PolicyGateExecutor<T: ToolExecutor> {
51 inner: T,
52 enforcer: Arc<PolicyEnforcer>,
53 context: Arc<RwLock<PolicyContext>>,
54 audit: Option<Arc<AuditLogger>>,
55 trajectory_risk: Option<TrajectoryRiskSlot>,
58 signal_queue: Option<RiskSignalQueue>,
60}
61
62impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for PolicyGateExecutor<T> {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("PolicyGateExecutor")
65 .field("inner", &self.inner)
66 .finish_non_exhaustive()
67 }
68}
69
70impl<T: ToolExecutor> PolicyGateExecutor<T> {
71 #[must_use]
73 pub fn new(
74 inner: T,
75 enforcer: Arc<PolicyEnforcer>,
76 context: Arc<RwLock<PolicyContext>>,
77 ) -> Self {
78 Self {
79 inner,
80 enforcer,
81 context,
82 audit: None,
83 trajectory_risk: None,
84 signal_queue: None,
85 }
86 }
87
88 #[must_use]
90 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
91 self.audit = Some(audit);
92 self
93 }
94
95 #[must_use]
100 pub fn with_trajectory_risk(mut self, slot: TrajectoryRiskSlot) -> Self {
101 self.trajectory_risk = Some(slot);
102 self
103 }
104
105 #[must_use]
109 pub fn with_signal_queue(mut self, queue: RiskSignalQueue) -> Self {
110 self.signal_queue = Some(queue);
111 self
112 }
113
114 fn push_signal(&self, code: u8) {
115 if let Some(ref q) = self.signal_queue {
116 q.lock().push(code);
117 }
118 }
119
120 fn read_context(&self) -> PolicyContext {
121 self.context.read().clone()
122 }
123
124 #[cfg(test)]
125 fn trust_level_for_test(&self) -> crate::SkillTrustLevel {
126 self.context.read().trust_level
127 }
128
129 pub fn update_context(&self, new_ctx: PolicyContext) {
141 *self.context.write() = new_ctx;
142 }
143
144 fn is_trajectory_critical(&self) -> bool {
146 self.trajectory_risk
147 .as_ref()
148 .is_some_and(|slot| *slot.read() >= 3)
149 }
150
151 async fn log_audit(&self, call: &ToolCall, result: AuditResult, error_category: Option<&str>) {
152 let Some(audit) = &self.audit else { return };
153 let entry = AuditEntry {
154 timestamp: chrono_now(),
155 tool: call.tool_id.clone(),
156 command: truncate_params(&call.params),
157 result,
158 duration_ms: 0,
159 error_category: error_category.map(str::to_owned),
160 error_domain: error_category.map(|_| "security".to_owned()),
161 error_phase: None,
162 claim_source: None,
163 mcp_server_id: None,
164 injection_flagged: false,
165 embedding_anomalous: false,
166 cross_boundary_mcp_to_acp: false,
167 adversarial_policy_decision: None,
168 exit_code: None,
169 truncated: false,
170 caller_id: call.caller_id.clone(),
171 skill_name: call.skill_name.clone(),
172 policy_match: None,
173 correlation_id: None,
174 vigil_risk: None,
175 execution_env: None,
176 resolved_cwd: None,
177 scope_at_definition: None,
178 scope_at_dispatch: None,
179 };
180 audit.log(&entry).await;
181 }
182
183 async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
184 if self.is_trajectory_critical() {
186 tracing::warn!(tool = %call.tool_id, "trajectory sentinel at Critical: denied (spec 050)");
187 self.log_audit(
188 call,
189 AuditResult::Blocked {
190 reason: "trajectory_critical_downgrade".to_owned(),
191 },
192 Some("trajectory_critical_downgrade"),
193 )
194 .await;
195 return Err(ToolError::Blocked {
196 command: "Tool call denied by policy".to_owned(),
197 });
198 }
199
200 let ctx = self.read_context();
201 let decision = self
202 .enforcer
203 .evaluate(call.tool_id.as_str(), &call.params, &ctx);
204
205 match &decision {
206 PolicyDecision::Allow { trace } => {
207 debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
208 if let Some(audit) = &self.audit {
209 let entry = AuditEntry {
210 timestamp: chrono_now(),
211 tool: call.tool_id.clone(),
212 command: truncate_params(&call.params),
213 result: AuditResult::Success,
214 duration_ms: 0,
215 error_category: None,
216 error_domain: None,
217 error_phase: None,
218 claim_source: None,
219 mcp_server_id: None,
220 injection_flagged: false,
221 embedding_anomalous: false,
222 cross_boundary_mcp_to_acp: false,
223 adversarial_policy_decision: None,
224 exit_code: None,
225 truncated: false,
226 caller_id: call.caller_id.clone(),
227 skill_name: call.skill_name.clone(),
228 policy_match: Some(trace.clone()),
229 correlation_id: None,
230 vigil_risk: None,
231 execution_env: None,
232 resolved_cwd: None,
233 scope_at_definition: None,
234 scope_at_dispatch: None,
235 };
236 audit.log(&entry).await;
237 }
238 Ok(())
239 }
240 PolicyDecision::Deny { trace } => {
241 debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
242 self.push_signal(1);
244 if let Some(audit) = &self.audit {
245 let entry = AuditEntry {
246 timestamp: chrono_now(),
247 tool: call.tool_id.clone(),
248 command: truncate_params(&call.params),
249 result: AuditResult::Blocked {
250 reason: trace.clone(),
251 },
252 duration_ms: 0,
253 error_category: Some("policy_blocked".to_owned()),
254 error_domain: Some("action".to_owned()),
255 error_phase: None,
256 claim_source: None,
257 mcp_server_id: None,
258 injection_flagged: false,
259 embedding_anomalous: false,
260 cross_boundary_mcp_to_acp: false,
261 adversarial_policy_decision: None,
262 exit_code: None,
263 truncated: false,
264 caller_id: call.caller_id.clone(),
265 skill_name: call.skill_name.clone(),
266 policy_match: Some(trace.clone()),
267 correlation_id: None,
268 vigil_risk: None,
269 execution_env: None,
270 resolved_cwd: None,
271 scope_at_definition: None,
272 scope_at_dispatch: None,
273 };
274 audit.log(&entry).await;
275 }
276 Err(ToolError::Blocked {
278 command: "Tool call denied by policy".to_owned(),
279 })
280 }
281 }
282 }
283}
284
285impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
286 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
289 Err(ToolError::Blocked {
290 command:
291 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
292 .into(),
293 })
294 }
295
296 async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
297 Err(ToolError::Blocked {
298 command:
299 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
300 .into(),
301 })
302 }
303
304 fn tool_definitions(&self) -> Vec<ToolDef> {
305 self.inner.tool_definitions()
306 }
307
308 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
309 self.check_policy(call).await?;
310 let result = self.inner.execute_tool_call(call).await;
311 if let Ok(Some(ref output)) = result
314 && let Some(colon) = output.tool_name.as_str().find(':')
315 {
316 let server_id = output.tool_name.as_str()[..colon].to_owned();
317 if let Some(audit) = &self.audit {
318 let entry = AuditEntry {
319 timestamp: chrono_now(),
320 tool: call.tool_id.clone(),
321 command: truncate_params(&call.params),
322 result: AuditResult::Success,
323 duration_ms: 0,
324 error_category: None,
325 error_domain: None,
326 error_phase: None,
327 claim_source: None,
328 mcp_server_id: Some(server_id),
329 injection_flagged: false,
330 embedding_anomalous: false,
331 cross_boundary_mcp_to_acp: false,
332 adversarial_policy_decision: None,
333 exit_code: None,
334 truncated: false,
335 caller_id: call.caller_id.clone(),
336 skill_name: call.skill_name.clone(),
337 policy_match: None,
338 correlation_id: None,
339 vigil_risk: None,
340 execution_env: None,
341 resolved_cwd: None,
342 scope_at_definition: None,
343 scope_at_dispatch: None,
344 };
345 audit.log(&entry).await;
346 }
347 }
348 result
349 }
350
351 async fn execute_tool_call_confirmed(
354 &self,
355 call: &ToolCall,
356 ) -> Result<Option<ToolOutput>, ToolError> {
357 self.check_policy(call).await?;
358 self.inner.execute_tool_call_confirmed(call).await
359 }
360
361 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
362 self.inner.set_skill_env(env);
363 }
364
365 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
366 let mut ctx = self.context.write();
370 ctx.trust_level = ctx.trust_level.min_trust(level);
371 let effective = ctx.trust_level;
372 drop(ctx);
373 self.inner.set_effective_trust(effective);
374 }
375
376 fn is_tool_retryable(&self, tool_id: &str) -> bool {
377 self.inner.is_tool_retryable(tool_id)
378 }
379
380 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
381 self.inner.is_tool_speculatable(tool_id)
382 }
383
384 fn requires_confirmation(&self, call: &ToolCall) -> bool {
385 self.inner.requires_confirmation(call)
386 }
387
388 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
389 self.inner.checkpoint_undo(n)
390 }
391
392 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
393 self.inner.checkpoint_redo()
394 }
395
396 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
397 self.inner.checkpoint_list()
398 }
399}
400
401fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
402 let s = serde_json::to_string(params).unwrap_or_default();
403 if s.chars().count() > 500 {
404 let truncated: String = s.chars().take(497).collect();
405 format!("{truncated}…")
406 } else {
407 s
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use std::assert_matches;
414 use std::collections::HashMap;
415 use std::sync::Arc;
416
417 use zeph_config::ProviderName;
418
419 use super::*;
420 use crate::SkillTrustLevel;
421 use crate::policy::{
422 DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
423 };
424
425 #[derive(Debug)]
426 struct MockExecutor;
427
428 impl ToolExecutor for MockExecutor {
429 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
430 Ok(None)
431 }
432 async fn execute_tool_call(
433 &self,
434 call: &ToolCall,
435 ) -> Result<Option<ToolOutput>, ToolError> {
436 Ok(Some(ToolOutput {
437 tool_name: call.tool_id.clone(),
438 summary: "ok".into(),
439 blocks_executed: 1,
440 filter_stats: None,
441 diff: None,
442 streamed: false,
443 terminal_id: None,
444 locations: None,
445 raw_response: None,
446 claim_source: None,
447 ..Default::default()
448 }))
449 }
450
451 crate::tool_executor_no_inner_defaults!();
452 }
453
454 fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
455 let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
456 let context = Arc::new(RwLock::new(PolicyContext {
457 trust_level: SkillTrustLevel::Trusted,
458 env: HashMap::new(),
459 }));
460 PolicyGateExecutor::new(MockExecutor, enforcer, context)
461 }
462
463 fn make_call(tool_id: &str) -> ToolCall {
464 ToolCall {
465 tool_id: tool_id.into(),
466 params: serde_json::Map::new(),
467 caller_id: None,
468 context: None,
469
470 tool_call_id: String::new(),
471 skill_name: None,
472 }
473 }
474
475 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
476 let mut params = serde_json::Map::new();
477 params.insert("file_path".into(), serde_json::Value::String(path.into()));
478 ToolCall {
479 tool_id: tool_id.into(),
480 params,
481 caller_id: None,
482 context: None,
483
484 tool_call_id: String::new(),
485 skill_name: None,
486 }
487 }
488
489 #[derive(Debug)]
490 struct CheckpointingExecutor;
491
492 impl ToolExecutor for CheckpointingExecutor {
493 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
494 Ok(None)
495 }
496 async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
497 Ok(None)
498 }
499 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
500 crate::executor::CheckpointActionResult {
501 supported: true,
502 message: "stub".into(),
503 reverted_commands: n,
504 ..Default::default()
505 }
506 }
507 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
508 crate::executor::CheckpointActionResult {
509 supported: true,
510 message: "stub".into(),
511 ..Default::default()
512 }
513 }
514 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
515 crate::executor::CheckpointListResult {
516 supported: true,
517 ..Default::default()
518 }
519 }
520 async fn execute_tool_call_confirmed(
521 &self,
522 call: &ToolCall,
523 ) -> Result<Option<ToolOutput>, ToolError> {
524 self.execute_tool_call(call).await
525 }
526 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
527 false
528 }
529 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
530 false
531 }
532 }
533
534 #[derive(Debug)]
538 struct ConfirmationRequiredExecutor;
539
540 impl ToolExecutor for ConfirmationRequiredExecutor {
541 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
542 Ok(None)
543 }
544 async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
545 Ok(None)
546 }
547 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
548 true
549 }
550
551 async fn execute_tool_call_confirmed(
552 &self,
553 call: &ToolCall,
554 ) -> Result<Option<ToolOutput>, ToolError> {
555 self.execute_tool_call(call).await
556 }
557 fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
558 crate::executor::CheckpointActionResult::unsupported()
559 }
560 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
561 crate::executor::CheckpointActionResult::unsupported()
562 }
563 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
564 crate::executor::CheckpointListResult::default()
565 }
566 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
567 false
568 }
569 }
570
571 #[test]
572 fn requires_confirmation_delegated_to_inner() {
573 let config = PolicyConfig {
574 enabled: false,
575 default_effect: DefaultEffect::Allow,
576 rules: vec![],
577 policy_file: None,
578 policy_provider: ProviderName::default(),
579 };
580 let enforcer = Arc::new(PolicyEnforcer::compile(&config).unwrap());
581 let context = Arc::new(RwLock::new(PolicyContext {
582 trust_level: SkillTrustLevel::Trusted,
583 env: HashMap::new(),
584 }));
585 let gate = PolicyGateExecutor::new(ConfirmationRequiredExecutor, enforcer, context);
586 assert!(
587 gate.requires_confirmation(&make_call("shell")),
588 "requires_confirmation must be forwarded to the inner executor's non-default value"
589 );
590 }
591
592 #[test]
593 fn checkpoint_methods_delegated_to_inner() {
594 let config = PolicyConfig {
595 enabled: false,
596 default_effect: DefaultEffect::Allow,
597 rules: vec![],
598 policy_file: None,
599 policy_provider: ProviderName::default(),
600 };
601 let enforcer = Arc::new(PolicyEnforcer::compile(&config).unwrap());
602 let context = Arc::new(RwLock::new(PolicyContext {
603 trust_level: SkillTrustLevel::Trusted,
604 env: HashMap::new(),
605 }));
606 let gate = PolicyGateExecutor::new(CheckpointingExecutor, enforcer, context);
607 let undo_result = gate.checkpoint_undo(7);
608 assert!(undo_result.supported);
609 assert_eq!(
610 undo_result.reverted_commands, 7,
611 "n must be forwarded, not hardcoded"
612 );
613 assert!(gate.checkpoint_redo().supported);
614 assert!(gate.checkpoint_list().supported);
615 }
616
617 #[tokio::test]
618 async fn allow_by_default_when_default_allow() {
619 let config = PolicyConfig {
620 enabled: true,
621 default_effect: DefaultEffect::Allow,
622 rules: vec![],
623 policy_file: None,
624 policy_provider: ProviderName::default(),
625 };
626 let gate = make_gate(&config);
627 let result = gate.execute_tool_call(&make_call("bash")).await;
628 assert!(result.is_ok());
629 }
630
631 #[tokio::test]
632 async fn deny_by_default_when_default_deny() {
633 let config = PolicyConfig {
634 enabled: true,
635 default_effect: DefaultEffect::Deny,
636 rules: vec![],
637 policy_file: None,
638 policy_provider: ProviderName::default(),
639 };
640 let gate = make_gate(&config);
641 let result = gate.execute_tool_call(&make_call("bash")).await;
642 assert_matches!(result, Err(ToolError::Blocked { .. }));
643 }
644
645 #[tokio::test]
646 async fn deny_rule_blocks_tool() {
647 let config = PolicyConfig {
648 enabled: true,
649 default_effect: DefaultEffect::Allow,
650 rules: vec![PolicyRuleConfig {
651 effect: PolicyEffect::Deny,
652 tool: "shell".into(),
653 paths: vec!["/etc/*".to_owned()],
654 env: vec![],
655 trust_level: None,
656 args_match: None,
657 capabilities: vec![],
658 }],
659 policy_file: None,
660 policy_provider: ProviderName::default(),
661 };
662 let gate = make_gate(&config);
663 let result = gate
664 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
665 .await;
666 assert_matches!(result, Err(ToolError::Blocked { .. }));
667 }
668
669 #[tokio::test]
670 async fn allow_rule_permits_tool() {
671 let config = PolicyConfig {
672 enabled: true,
673 default_effect: DefaultEffect::Deny,
674 rules: vec![PolicyRuleConfig {
675 effect: PolicyEffect::Allow,
676 tool: "shell".into(),
677 paths: vec!["/tmp/*".to_owned()],
678 env: vec![],
679 trust_level: None,
680 args_match: None,
681 capabilities: vec![],
682 }],
683 policy_file: None,
684 policy_provider: ProviderName::default(),
685 };
686 let gate = make_gate(&config);
687 let result = gate
688 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
689 .await;
690 assert!(result.is_ok());
691 }
692
693 #[tokio::test]
694 async fn error_message_is_generic() {
695 let config = PolicyConfig {
697 enabled: true,
698 default_effect: DefaultEffect::Deny,
699 rules: vec![],
700 policy_file: None,
701 policy_provider: ProviderName::default(),
702 };
703 let gate = make_gate(&config);
704 let err = gate
705 .execute_tool_call(&make_call("bash"))
706 .await
707 .unwrap_err();
708 if let ToolError::Blocked { command } = err {
709 assert!(!command.contains("rule["), "must not leak rule index");
710 assert!(!command.contains("/etc/"), "must not leak path pattern");
711 } else {
712 panic!("expected Blocked error");
713 }
714 }
715
716 #[tokio::test]
717 async fn confirmed_also_enforces_policy() {
718 let config = PolicyConfig {
720 enabled: true,
721 default_effect: DefaultEffect::Deny,
722 rules: vec![],
723 policy_file: None,
724 policy_provider: ProviderName::default(),
725 };
726 let gate = make_gate(&config);
727 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
728 assert_matches!(result, Err(ToolError::Blocked { .. }));
729 }
730
731 #[tokio::test]
733 async fn confirmed_allow_delegates_to_inner() {
734 let config = PolicyConfig {
735 enabled: true,
736 default_effect: DefaultEffect::Allow,
737 rules: vec![],
738 policy_file: None,
739 policy_provider: ProviderName::default(),
740 };
741 let gate = make_gate(&config);
742 let call = make_call("shell");
743 let result = gate.execute_tool_call_confirmed(&call).await;
744 assert!(result.is_ok(), "allow path must not return an error");
745 let output = result.unwrap();
746 assert!(
747 output.is_some(),
748 "inner executor must be invoked and return output on allow"
749 );
750 assert_eq!(
751 output.unwrap().tool_name,
752 "shell",
753 "output tool_name must match the confirmed call"
754 );
755 }
756
757 #[tokio::test]
758 async fn legacy_execute_blocked_when_policy_enabled() {
759 let config = PolicyConfig {
762 enabled: true,
763 default_effect: DefaultEffect::Deny,
764 rules: vec![],
765 policy_file: None,
766 policy_provider: ProviderName::default(),
767 };
768 let gate = make_gate(&config);
769 let result = gate.execute("```bash\necho hi\n```").await;
770 assert_matches!(result, Err(ToolError::Blocked { .. }));
771 let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
772 assert_matches!(result_confirmed, Err(ToolError::Blocked { .. }));
773 }
774
775 #[tokio::test]
778 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
779 let config = PolicyConfig {
783 enabled: true,
784 default_effect: DefaultEffect::Deny,
785 rules: vec![PolicyRuleConfig {
786 effect: PolicyEffect::Allow,
787 tool: "shell".into(),
788 paths: vec![],
789 env: vec![],
790 trust_level: Some(SkillTrustLevel::Verified),
791 args_match: None,
792 capabilities: vec![],
793 }],
794 policy_file: None,
795 policy_provider: ProviderName::default(),
796 };
797 let gate = make_gate(&config);
798 gate.set_effective_trust(SkillTrustLevel::Quarantined);
799 let result = gate.execute_tool_call(&make_call("shell")).await;
800 assert!(
801 matches!(result, Err(ToolError::Blocked { .. })),
802 "Quarantined context must not satisfy a Verified trust threshold allow rule"
803 );
804 }
805
806 #[tokio::test]
807 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
808 let config = PolicyConfig {
812 enabled: true,
813 default_effect: DefaultEffect::Deny,
814 rules: vec![PolicyRuleConfig {
815 effect: PolicyEffect::Allow,
816 tool: "shell".into(),
817 paths: vec![],
818 env: vec![],
819 trust_level: Some(SkillTrustLevel::Verified),
820 args_match: None,
821 capabilities: vec![],
822 }],
823 policy_file: None,
824 policy_provider: ProviderName::default(),
825 };
826 let gate = make_gate(&config);
827 gate.set_effective_trust(SkillTrustLevel::Trusted);
828 let result = gate.execute_tool_call(&make_call("shell")).await;
829 assert!(
830 result.is_ok(),
831 "Trusted context must satisfy a Verified trust threshold allow rule"
832 );
833 }
834
835 #[tokio::test]
837 async fn critical_trajectory_blocks_any_allow() {
838 let config = PolicyConfig {
839 enabled: true,
840 default_effect: DefaultEffect::Allow,
841 rules: vec![],
842 policy_file: None,
843 policy_provider: ProviderName::default(),
844 };
845 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
847 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
848 assert!(
849 matches!(result, Err(ToolError::Blocked { .. })),
850 "Critical trajectory must block even policy-allowed tool calls"
851 );
852 if let Err(ToolError::Blocked { command }) = result {
854 assert!(
855 !command.contains("Critical") && !command.contains("trajectory"),
856 "error message must not leak risk info to LLM: got '{command}'"
857 );
858 }
859 }
860
861 #[tokio::test]
863 async fn high_trajectory_does_not_block_allowed_tool() {
864 let config = PolicyConfig {
865 enabled: true,
866 default_effect: DefaultEffect::Allow,
867 rules: vec![],
868 policy_file: None,
869 policy_provider: ProviderName::default(),
870 };
871 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
873 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
874 assert!(
875 result.is_ok(),
876 "High (not Critical) must not block allowed tool calls"
877 );
878 }
879
880 #[test]
883 fn set_effective_trust_lower_trust_cap_narrows_down() {
884 let config = PolicyConfig {
887 enabled: false,
888 default_effect: DefaultEffect::Allow,
889 rules: vec![],
890 policy_file: None,
891 policy_provider: ProviderName::default(),
892 };
893 let gate = make_gate(&config);
894 gate.set_effective_trust(SkillTrustLevel::Quarantined);
896 assert_eq!(
897 gate.trust_level_for_test(),
898 SkillTrustLevel::Quarantined,
899 "cap with lower trust must narrow executor trust level"
900 );
901 }
902
903 #[test]
904 fn set_effective_trust_higher_trust_cap_does_not_raise() {
905 let config = PolicyConfig {
908 enabled: false,
909 default_effect: DefaultEffect::Allow,
910 rules: vec![],
911 policy_file: None,
912 policy_provider: ProviderName::default(),
913 };
914 let gate = make_gate(&config);
915 gate.update_context(PolicyContext {
917 trust_level: SkillTrustLevel::Quarantined,
918 env: std::collections::HashMap::new(),
919 });
920 gate.set_effective_trust(SkillTrustLevel::Trusted);
922 assert_eq!(
923 gate.trust_level_for_test(),
924 SkillTrustLevel::Quarantined,
925 "cap with higher trust must NOT raise executor trust level"
926 );
927 }
928}