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