1use std::collections::HashSet;
7use std::sync::{
8 Arc,
9 atomic::{AtomicU8, Ordering},
10};
11
12use parking_lot::RwLock;
13
14use crate::SkillTrustLevel;
15
16use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
17use crate::permissions::{AutonomyLevel, PermissionAction, PermissionPolicy};
18use crate::registry::ToolDef;
19
20pub use zeph_common::quarantine::QUARANTINE_DENIED;
26
27pub(crate) fn is_quarantine_denied(tool_id: &str) -> bool {
28 QUARANTINE_DENIED
29 .iter()
30 .any(|denied| tool_id == *denied || tool_id.ends_with(&format!("_{denied}")))
31}
32
33pub(crate) fn quarantine_denial_message(tool_id: &str, active_skills: &[String]) -> String {
45 if active_skills.is_empty() {
46 format!("{tool_id} denied (trust=quarantined)")
47 } else {
48 format!(
49 "{tool_id} denied: this turn's active skill set {active_skills:?} has a combined \
50 trust floor of quarantined (weakest-link policy over all co-active skills this \
51 turn; this reflects the turn's overall trust floor and may not be about the \
52 specific tool/skill you targeted)"
53 )
54 }
55}
56
57pub(crate) fn trust_to_u8(level: SkillTrustLevel) -> u8 {
58 match level {
59 SkillTrustLevel::Trusted => 0,
60 SkillTrustLevel::Verified => 1,
61 SkillTrustLevel::Quarantined => 2,
62 _ => 3,
63 }
64}
65
66pub(crate) fn u8_to_trust(v: u8) -> SkillTrustLevel {
67 match v {
68 0 => SkillTrustLevel::Trusted,
69 1 => SkillTrustLevel::Verified,
70 2 => SkillTrustLevel::Quarantined,
71 _ => SkillTrustLevel::Blocked,
72 }
73}
74
75pub struct TrustGateExecutor<T: ToolExecutor> {
77 inner: T,
78 policy: PermissionPolicy,
79 effective_trust: AtomicU8,
80 mcp_tool_ids: Arc<RwLock<HashSet<String>>>,
85}
86
87impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for TrustGateExecutor<T> {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("TrustGateExecutor")
90 .field("inner", &self.inner)
91 .field("policy", &self.policy)
92 .field("effective_trust", &self.effective_trust())
93 .field("mcp_tool_ids", &self.mcp_tool_ids)
94 .finish()
95 }
96}
97
98impl<T: ToolExecutor> TrustGateExecutor<T> {
99 #[must_use]
100 pub fn new(inner: T, policy: PermissionPolicy) -> Self {
101 Self {
102 inner,
103 policy,
104 effective_trust: AtomicU8::new(trust_to_u8(SkillTrustLevel::Trusted)),
105 mcp_tool_ids: Arc::new(RwLock::new(HashSet::new())),
106 }
107 }
108
109 #[must_use]
113 pub fn mcp_tool_ids_handle(&self) -> Arc<RwLock<HashSet<String>>> {
114 Arc::clone(&self.mcp_tool_ids)
115 }
116
117 pub fn set_effective_trust(&self, level: SkillTrustLevel) {
118 self.effective_trust
119 .store(trust_to_u8(level), Ordering::Relaxed);
120 }
121
122 #[must_use]
123 pub fn effective_trust(&self) -> SkillTrustLevel {
124 u8_to_trust(self.effective_trust.load(Ordering::Relaxed))
125 }
126
127 fn is_mcp_tool(&self, tool_id: &str) -> bool {
128 self.mcp_tool_ids.read().contains(tool_id)
129 }
130
131 fn check_trust(
146 &self,
147 tool_id: &str,
148 input: &str,
149 active_skills: &[String],
150 ) -> Result<(), ToolError> {
151 match self.effective_trust() {
152 SkillTrustLevel::Blocked => {
153 return Err(ToolError::Blocked {
154 command: "all tools blocked (trust=blocked)".to_owned(),
155 });
156 }
157 SkillTrustLevel::Quarantined
158 if is_quarantine_denied(tool_id) || self.is_mcp_tool(tool_id) =>
159 {
160 return Err(ToolError::Blocked {
161 command: quarantine_denial_message(tool_id, active_skills),
162 });
163 }
164 _ => {}
165 }
166
167 if self.policy.autonomy_level() == AutonomyLevel::Supervised
178 && self.policy.rules().get(tool_id).is_none()
179 && (self.is_mcp_tool(tool_id) || crate::permissions::is_readonly_tool(tool_id))
180 {
181 return Ok(());
182 }
183
184 match self.policy.check(tool_id, input) {
185 PermissionAction::Allow => Ok(()),
186 PermissionAction::Ask => Err(ToolError::ConfirmationRequired {
187 command: input.to_owned(),
188 }),
189 _ => Err(ToolError::Blocked {
190 command: input.to_owned(),
191 }),
192 }
193 }
194}
195
196impl<T: ToolExecutor> ToolExecutor for TrustGateExecutor<T> {
197 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
198 match self.effective_trust() {
202 SkillTrustLevel::Blocked | SkillTrustLevel::Quarantined => {
203 return Err(ToolError::Blocked {
204 command: format!(
205 "tool execution denied (trust={})",
206 format!("{:?}", self.effective_trust()).to_lowercase()
207 ),
208 });
209 }
210 _ => {}
211 }
212 self.inner.execute(response).await
213 }
214
215 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
216 match self.effective_trust() {
218 SkillTrustLevel::Blocked | SkillTrustLevel::Quarantined => {
219 return Err(ToolError::Blocked {
220 command: format!(
221 "tool execution denied (trust={})",
222 format!("{:?}", self.effective_trust()).to_lowercase()
223 ),
224 });
225 }
226 _ => {}
227 }
228 self.inner.execute_confirmed(response).await
229 }
230
231 fn tool_definitions(&self) -> Vec<ToolDef> {
232 self.inner.tool_definitions()
233 }
234
235 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
236 let input = call
237 .params
238 .get("command")
239 .or_else(|| call.params.get("file_path"))
240 .or_else(|| call.params.get("query"))
241 .or_else(|| call.params.get("url"))
242 .or_else(|| call.params.get("uri"))
243 .and_then(|v| v.as_str())
244 .unwrap_or("");
245 self.check_trust(
246 call.tool_id.as_str(),
247 input,
248 call.skill_name.as_deref().unwrap_or(&[]),
249 )?;
250 self.inner.execute_tool_call(call).await
251 }
252
253 async fn execute_tool_call_confirmed(
254 &self,
255 call: &ToolCall,
256 ) -> Result<Option<ToolOutput>, ToolError> {
257 match self.effective_trust() {
261 SkillTrustLevel::Blocked => {
262 return Err(ToolError::Blocked {
263 command: "all tools blocked (trust=blocked)".to_owned(),
264 });
265 }
266 SkillTrustLevel::Quarantined
267 if is_quarantine_denied(call.tool_id.as_str())
268 || self.is_mcp_tool(call.tool_id.as_str()) =>
269 {
270 return Err(ToolError::Blocked {
271 command: quarantine_denial_message(
272 call.tool_id.as_str(),
273 call.skill_name.as_deref().unwrap_or(&[]),
274 ),
275 });
276 }
277 _ => {}
278 }
279 self.inner.execute_tool_call_confirmed(call).await
280 }
281
282 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
283 self.inner.set_skill_env(env);
284 }
285
286 fn is_tool_retryable(&self, tool_id: &str) -> bool {
287 self.inner.is_tool_retryable(tool_id)
288 }
289
290 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
291 self.inner.is_tool_speculatable(tool_id)
292 }
293
294 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
295 self.inner.checkpoint_undo(n)
296 }
297
298 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
299 self.inner.checkpoint_redo()
300 }
301
302 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
303 self.inner.checkpoint_list()
304 }
305
306 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
307 self.effective_trust
308 .store(trust_to_u8(level), Ordering::Relaxed);
309 }
310
311 fn requires_confirmation(&self, call: &crate::executor::ToolCall) -> bool {
317 let input = call
318 .params
319 .get("command")
320 .or_else(|| call.params.get("file_path"))
321 .or_else(|| call.params.get("query"))
322 .or_else(|| call.params.get("url"))
323 .or_else(|| call.params.get("uri"))
324 .and_then(|v| v.as_str())
325 .unwrap_or("");
326 matches!(
327 self.check_trust(
328 call.tool_id.as_str(),
329 input,
330 call.skill_name.as_deref().unwrap_or(&[]),
331 ),
332 Err(ToolError::ConfirmationRequired { .. })
333 )
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use std::assert_matches;
341
342 #[derive(Debug)]
343 struct MockExecutor;
344 impl ToolExecutor for MockExecutor {
345 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
346 Ok(None)
347 }
348 async fn execute_tool_call(
349 &self,
350 call: &ToolCall,
351 ) -> Result<Option<ToolOutput>, ToolError> {
352 Ok(Some(ToolOutput {
353 tool_name: call.tool_id.clone(),
354 summary: "ok".into(),
355 blocks_executed: 1,
356 filter_stats: None,
357 diff: None,
358 streamed: false,
359 terminal_id: None,
360 locations: None,
361 raw_response: None,
362 claim_source: None,
363 ..Default::default()
364 }))
365 }
366
367 crate::tool_executor_no_inner_defaults!();
368 }
369
370 fn make_call(tool_id: &str) -> ToolCall {
371 ToolCall {
372 tool_id: tool_id.into(),
373 params: serde_json::Map::new(),
374 caller_id: None,
375 context: None,
376
377 tool_call_id: String::new(),
378 skill_name: None,
379 }
380 }
381
382 fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
383 let mut params = serde_json::Map::new();
384 params.insert("command".into(), serde_json::Value::String(cmd.into()));
385 ToolCall {
386 tool_id: tool_id.into(),
387 params,
388 caller_id: None,
389 context: None,
390
391 tool_call_id: String::new(),
392 skill_name: None,
393 }
394 }
395
396 fn make_call_with_skills(tool_id: &str, skills: &[&str]) -> ToolCall {
397 ToolCall {
398 tool_id: tool_id.into(),
399 params: serde_json::Map::new(),
400 caller_id: None,
401 context: None,
402
403 tool_call_id: String::new(),
404 skill_name: Some(skills.iter().map(ToString::to_string).collect()),
405 }
406 }
407
408 fn blocked_command(result: Result<Option<ToolOutput>, ToolError>) -> String {
409 match result {
410 Err(ToolError::Blocked { command }) => command,
411 other => panic!("expected Err(ToolError::Blocked {{ .. }}), got {other:?}"),
412 }
413 }
414
415 #[tokio::test]
416 async fn supervised_readonly_native_tool_without_rule_allowed() {
417 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
420 gate.set_effective_trust(SkillTrustLevel::Trusted);
421
422 let result = gate.execute_tool_call(&make_call("read")).await;
423 assert!(result.is_ok());
424 }
425
426 #[tokio::test]
430 async fn supervised_unconfigured_non_mcp_non_readonly_tool_requires_confirmation() {
431 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
432 gate.set_effective_trust(SkillTrustLevel::Trusted);
433
434 let result = gate.execute_tool_call(&make_call("bash")).await;
435 assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
436 }
437
438 #[tokio::test]
443 async fn supervised_unconfigured_diagnostics_requires_confirmation() {
444 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
445 gate.set_effective_trust(SkillTrustLevel::Trusted);
446
447 let result = gate.execute_tool_call(&make_call("diagnostics")).await;
448 assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
449 }
450
451 #[tokio::test]
452 async fn quarantined_denies_bash() {
453 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
454 gate.set_effective_trust(SkillTrustLevel::Quarantined);
455
456 let result = gate.execute_tool_call(&make_call("bash")).await;
457 assert_matches!(result, Err(ToolError::Blocked { .. }));
458 }
459
460 #[tokio::test]
461 async fn quarantined_denies_write() {
462 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
463 gate.set_effective_trust(SkillTrustLevel::Quarantined);
464
465 let result = gate.execute_tool_call(&make_call("write")).await;
466 assert_matches!(result, Err(ToolError::Blocked { .. }));
467 }
468
469 #[tokio::test]
470 async fn quarantined_denies_edit() {
471 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
472 gate.set_effective_trust(SkillTrustLevel::Quarantined);
473
474 let result = gate.execute_tool_call(&make_call("edit")).await;
475 assert_matches!(result, Err(ToolError::Blocked { .. }));
476 }
477
478 #[tokio::test]
479 async fn quarantined_denies_delete_path() {
480 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
481 gate.set_effective_trust(SkillTrustLevel::Quarantined);
482
483 let result = gate.execute_tool_call(&make_call("delete_path")).await;
484 assert_matches!(result, Err(ToolError::Blocked { .. }));
485 }
486
487 #[tokio::test]
488 async fn quarantined_denies_fetch() {
489 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
490 gate.set_effective_trust(SkillTrustLevel::Quarantined);
491
492 let result = gate.execute_tool_call(&make_call("fetch")).await;
493 assert_matches!(result, Err(ToolError::Blocked { .. }));
494 }
495
496 #[tokio::test]
497 async fn quarantined_denies_memory_save() {
498 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
499 gate.set_effective_trust(SkillTrustLevel::Quarantined);
500
501 let result = gate.execute_tool_call(&make_call("memory_save")).await;
502 assert_matches!(result, Err(ToolError::Blocked { .. }));
503 }
504
505 #[tokio::test]
511 async fn quarantined_denies_diagnostics() {
512 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
513 gate.set_effective_trust(SkillTrustLevel::Quarantined);
514
515 let result = gate.execute_tool_call(&make_call("diagnostics")).await;
516 assert_matches!(result, Err(ToolError::Blocked { .. }));
517 }
518
519 #[tokio::test]
520 async fn quarantined_allows_read() {
521 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
522 let gate = TrustGateExecutor::new(MockExecutor, policy);
523 gate.set_effective_trust(SkillTrustLevel::Quarantined);
524
525 let result = gate.execute_tool_call(&make_call("read")).await;
527 assert!(result.is_ok());
528 }
529
530 #[tokio::test]
531 async fn quarantined_allows_file_read() {
532 let mut rules = std::collections::HashMap::new();
537 rules.insert(
538 "file_read".to_owned(),
539 vec![crate::permissions::PermissionRule {
540 pattern: "*".to_owned(),
541 action: PermissionAction::Allow,
542 }],
543 );
544 let policy = crate::permissions::PermissionPolicy::new(rules);
545 let gate = TrustGateExecutor::new(MockExecutor, policy);
546 gate.set_effective_trust(SkillTrustLevel::Quarantined);
547
548 let result = gate.execute_tool_call(&make_call("file_read")).await;
549 assert!(result.is_ok());
551 }
552
553 #[tokio::test]
554 async fn blocked_denies_everything() {
555 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
556 gate.set_effective_trust(SkillTrustLevel::Blocked);
557
558 let result = gate.execute_tool_call(&make_call("file_read")).await;
559 assert_matches!(result, Err(ToolError::Blocked { .. }));
560 }
561
562 #[tokio::test]
563 async fn policy_deny_overrides_trust() {
564 let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
565 let gate = TrustGateExecutor::new(MockExecutor, policy);
566 gate.set_effective_trust(SkillTrustLevel::Trusted);
567
568 let result = gate
569 .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
570 .await;
571 assert_matches!(result, Err(ToolError::Blocked { .. }));
572 }
573
574 #[tokio::test]
575 async fn blocked_denies_execute() {
576 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
577 gate.set_effective_trust(SkillTrustLevel::Blocked);
578
579 let result = gate.execute("some response").await;
580 assert_matches!(result, Err(ToolError::Blocked { .. }));
581 }
582
583 #[tokio::test]
584 async fn blocked_denies_execute_confirmed() {
585 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
586 gate.set_effective_trust(SkillTrustLevel::Blocked);
587
588 let result = gate.execute_confirmed("some response").await;
589 assert_matches!(result, Err(ToolError::Blocked { .. }));
590 }
591
592 #[tokio::test]
593 async fn trusted_allows_execute() {
594 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
595 gate.set_effective_trust(SkillTrustLevel::Trusted);
596
597 let result = gate.execute("some response").await;
598 assert!(result.is_ok());
599 }
600
601 #[tokio::test]
602 async fn verified_with_allow_policy_succeeds() {
603 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
604 let gate = TrustGateExecutor::new(MockExecutor, policy);
605 gate.set_effective_trust(SkillTrustLevel::Verified);
606
607 let result = gate
608 .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
609 .await
610 .unwrap();
611 assert!(result.is_some());
612 }
613
614 #[tokio::test]
615 async fn quarantined_denies_web_scrape() {
616 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
617 gate.set_effective_trust(SkillTrustLevel::Quarantined);
618
619 let result = gate.execute_tool_call(&make_call("web_scrape")).await;
620 assert_matches!(result, Err(ToolError::Blocked { .. }));
621 }
622
623 #[tokio::test]
627 async fn quarantined_denial_message_names_active_skills_not_target_tool() {
628 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
629 gate.set_effective_trust(SkillTrustLevel::Quarantined);
630
631 let call =
632 make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
633 let result = gate.execute_tool_call(&call).await;
634 let message = blocked_command(result);
635
636 assert!(
637 message.contains("disk-usage") && message.contains("persona-customer-support"),
638 "message should name the actual active skills, got: {message}"
639 );
640 assert_ne!(
641 message, "invoke_skill denied (trust=quarantined)",
642 "message must not read as if invoke_skill itself is the untrusted party"
643 );
644 }
645
646 #[tokio::test]
651 async fn quarantined_denial_message_unchanged_when_no_active_skills() {
652 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
653 gate.set_effective_trust(SkillTrustLevel::Quarantined);
654
655 let result = gate.execute_tool_call(&make_call("invoke_skill")).await;
656 let message = blocked_command(result);
657
658 assert_eq!(message, "invoke_skill denied (trust=quarantined)");
659 }
660
661 #[tokio::test]
664 async fn quarantined_denial_message_unchanged_when_active_skills_empty() {
665 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
666 gate.set_effective_trust(SkillTrustLevel::Quarantined);
667
668 let result = gate
669 .execute_tool_call(&make_call_with_skills("invoke_skill", &[]))
670 .await;
671 let message = blocked_command(result);
672
673 assert_eq!(message, "invoke_skill denied (trust=quarantined)");
674 }
675
676 #[tokio::test]
679 async fn quarantined_denial_message_names_active_skills_confirmed_path() {
680 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
681 gate.set_effective_trust(SkillTrustLevel::Quarantined);
682
683 let call =
684 make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
685 let result = gate.execute_tool_call_confirmed(&call).await;
686 let message = blocked_command(result);
687
688 assert!(
689 message.contains("disk-usage") && message.contains("persona-customer-support"),
690 "confirmed path message should name the actual active skills, got: {message}"
691 );
692 assert_ne!(
693 message, "invoke_skill denied (trust=quarantined)",
694 "confirmed path message must not read as if invoke_skill itself is untrusted"
695 );
696 }
697
698 #[tokio::test]
701 async fn quarantined_denial_message_names_active_skills_for_non_skill_tool() {
702 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
703 gate.set_effective_trust(SkillTrustLevel::Quarantined);
704
705 let call = make_call_with_skills("bash", &["disk-usage", "persona-customer-support"]);
706 let result = gate.execute_tool_call(&call).await;
707 let message = blocked_command(result);
708
709 assert!(
710 message.contains("disk-usage") && message.contains("persona-customer-support"),
711 "message for a non-skill tool should also name the active skills, got: {message}"
712 );
713 assert_ne!(message, "bash denied (trust=quarantined)");
714 }
715
716 #[derive(Debug)]
717 struct EnvCapture {
718 captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
719 }
720 impl EnvCapture {
721 fn new() -> Self {
722 Self {
723 captured: std::sync::Mutex::new(None),
724 }
725 }
726 }
727 impl ToolExecutor for EnvCapture {
728 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
729 Ok(None)
730 }
731 async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
732 Ok(None)
733 }
734 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
735 *self.captured.lock().unwrap() = env;
736 }
737
738 crate::tool_executor_no_inner_defaults!();
739 }
740
741 #[test]
742 fn is_tool_retryable_delegated_to_inner() {
743 #[derive(Debug)]
744 struct RetryableExecutor;
745 impl ToolExecutor for RetryableExecutor {
746 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
747 Ok(None)
748 }
749 async fn execute_tool_call(
750 &self,
751 _: &ToolCall,
752 ) -> Result<Option<ToolOutput>, ToolError> {
753 Ok(None)
754 }
755 fn is_tool_retryable(&self, tool_id: &str) -> bool {
756 tool_id == "fetch"
757 }
758
759 crate::tool_executor_no_inner_defaults!();
760 }
761 let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
762 assert!(gate.is_tool_retryable("fetch"));
763 assert!(!gate.is_tool_retryable("bash"));
764 }
765
766 #[test]
767 fn checkpoint_methods_delegated_to_inner() {
768 #[derive(Debug)]
769 struct CheckpointingExecutor;
770 impl ToolExecutor for CheckpointingExecutor {
771 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
772 Ok(None)
773 }
774 async fn execute_tool_call(
775 &self,
776 _: &ToolCall,
777 ) -> Result<Option<ToolOutput>, ToolError> {
778 Ok(None)
779 }
780 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
781 crate::executor::CheckpointActionResult {
782 supported: true,
783 message: "stub".into(),
784 reverted_commands: n,
785 ..Default::default()
786 }
787 }
788 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
789 crate::executor::CheckpointActionResult {
790 supported: true,
791 message: "stub".into(),
792 ..Default::default()
793 }
794 }
795 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
796 crate::executor::CheckpointListResult {
797 supported: true,
798 ..Default::default()
799 }
800 }
801 async fn execute_tool_call_confirmed(
802 &self,
803 call: &ToolCall,
804 ) -> Result<Option<ToolOutput>, ToolError> {
805 self.execute_tool_call(call).await
806 }
807 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
808 false
809 }
810 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
811 false
812 }
813 }
814 let gate = TrustGateExecutor::new(CheckpointingExecutor, PermissionPolicy::default());
815 let undo_result = gate.checkpoint_undo(7);
816 assert!(undo_result.supported);
817 assert_eq!(
818 undo_result.reverted_commands, 7,
819 "n must be forwarded, not hardcoded"
820 );
821 assert!(gate.checkpoint_redo().supported);
822 assert!(gate.checkpoint_list().supported);
823 }
824
825 #[test]
826 fn set_skill_env_forwarded_to_inner() {
827 let inner = EnvCapture::new();
828 let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
829
830 let mut env = std::collections::HashMap::new();
831 env.insert("MY_VAR".to_owned(), "42".to_owned());
832 gate.set_skill_env(Some(env.clone()));
833
834 let captured = gate.inner.captured.lock().unwrap();
835 assert_eq!(*captured, Some(env));
836 }
837
838 #[tokio::test]
839 async fn mcp_tool_supervised_no_rules_allows() {
840 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
844 let gate = TrustGateExecutor::new(MockExecutor, policy);
845 gate.set_effective_trust(SkillTrustLevel::Trusted);
846 gate.mcp_tool_ids_handle()
847 .write()
848 .insert("mcp_filesystem__read_file".to_owned());
849
850 let mut params = serde_json::Map::new();
851 params.insert(
852 "file_path".into(),
853 serde_json::Value::String("/tmp/test.txt".into()),
854 );
855 let call = ToolCall {
856 tool_id: "mcp_filesystem__read_file".into(),
857 params,
858 caller_id: None,
859 context: None,
860
861 tool_call_id: String::new(),
862 skill_name: None,
863 };
864 let result = gate.execute_tool_call(&call).await;
865 assert!(
866 result.is_ok(),
867 "MCP tool should be allowed when no rules exist"
868 );
869 }
870
871 #[tokio::test]
872 async fn bash_with_explicit_deny_rule_blocked() {
873 let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
875 let gate = TrustGateExecutor::new(MockExecutor, policy);
876 gate.set_effective_trust(SkillTrustLevel::Trusted);
877
878 let result = gate
879 .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
880 .await;
881 assert!(
882 matches!(result, Err(ToolError::Blocked { .. })),
883 "bash with explicit deny rule should be blocked"
884 );
885 }
886
887 #[tokio::test]
888 async fn bash_with_explicit_allow_rule_succeeds() {
889 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
891 let gate = TrustGateExecutor::new(MockExecutor, policy);
892 gate.set_effective_trust(SkillTrustLevel::Trusted);
893
894 let result = gate
895 .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
896 .await;
897 assert!(
898 result.is_ok(),
899 "bash with explicit allow rule should succeed"
900 );
901 }
902
903 #[tokio::test]
904 async fn readonly_denies_mcp_tool_not_in_allowlist() {
905 let policy =
907 crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
908 let gate = TrustGateExecutor::new(MockExecutor, policy);
909 gate.set_effective_trust(SkillTrustLevel::Trusted);
910
911 let result = gate
912 .execute_tool_call(&make_call("mcpls_get_diagnostics"))
913 .await;
914 assert!(
915 matches!(result, Err(ToolError::Blocked { .. })),
916 "ReadOnly mode must deny non-allowlisted tools"
917 );
918 }
919
920 #[test]
921 fn set_effective_trust_interior_mutability() {
922 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
923 assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
924
925 gate.set_effective_trust(SkillTrustLevel::Quarantined);
926 assert_eq!(gate.effective_trust(), SkillTrustLevel::Quarantined);
927
928 gate.set_effective_trust(SkillTrustLevel::Blocked);
929 assert_eq!(gate.effective_trust(), SkillTrustLevel::Blocked);
930
931 gate.set_effective_trust(SkillTrustLevel::Trusted);
932 assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
933 }
934
935 #[test]
938 fn is_quarantine_denied_exact_match() {
939 assert!(is_quarantine_denied("bash"));
940 assert!(is_quarantine_denied("write"));
941 assert!(is_quarantine_denied("fetch"));
942 assert!(is_quarantine_denied("memory_save"));
943 assert!(is_quarantine_denied("delete_path"));
944 assert!(is_quarantine_denied("create_directory"));
945 assert!(is_quarantine_denied("diagnostics"));
946 }
947
948 #[test]
949 fn is_quarantine_denied_suffix_match_mcp_write() {
950 assert!(is_quarantine_denied("filesystem_write"));
952 assert!(!is_quarantine_denied("filesystem_write_file"));
954 }
955
956 #[test]
957 fn is_quarantine_denied_suffix_mcp_bash() {
958 assert!(is_quarantine_denied("shell_bash"));
959 assert!(is_quarantine_denied("mcp_shell_bash"));
960 }
961
962 #[test]
963 fn is_quarantine_denied_suffix_mcp_fetch() {
964 assert!(is_quarantine_denied("http_fetch"));
965 assert!(!is_quarantine_denied("server_prefetch"));
967 }
968
969 #[test]
970 fn is_quarantine_denied_suffix_mcp_memory_save() {
971 assert!(is_quarantine_denied("server_memory_save"));
972 assert!(!is_quarantine_denied("server_save"));
974 }
975
976 #[test]
977 fn is_quarantine_denied_suffix_mcp_delete_path() {
978 assert!(is_quarantine_denied("fs_delete_path"));
979 assert!(is_quarantine_denied("fs_not_delete_path"));
981 }
982
983 #[test]
984 fn is_quarantine_denied_substring_not_suffix() {
985 assert!(!is_quarantine_denied("write_log"));
987 }
988
989 #[test]
990 fn is_quarantine_denied_read_only_tools_allowed() {
991 assert!(!is_quarantine_denied("filesystem_read_file"));
992 assert!(!is_quarantine_denied("filesystem_list_dir"));
993 assert!(!is_quarantine_denied("read"));
994 assert!(!is_quarantine_denied("file_read"));
995 }
996
997 #[tokio::test]
998 async fn quarantined_denies_mcp_write_tool() {
999 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1000 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1001
1002 let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
1003 assert_matches!(result, Err(ToolError::Blocked { .. }));
1004 }
1005
1006 #[tokio::test]
1007 async fn quarantined_allows_mcp_read_file() {
1008 let mut rules = std::collections::HashMap::new();
1015 rules.insert(
1016 "filesystem_read_file".to_owned(),
1017 vec![crate::permissions::PermissionRule {
1018 pattern: "*".to_owned(),
1019 action: PermissionAction::Allow,
1020 }],
1021 );
1022 let policy = crate::permissions::PermissionPolicy::new(rules);
1023 let gate = TrustGateExecutor::new(MockExecutor, policy);
1024 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1025
1026 let result = gate
1027 .execute_tool_call(&make_call("filesystem_read_file"))
1028 .await;
1029 assert!(result.is_ok());
1030 }
1031
1032 #[tokio::test]
1033 async fn quarantined_denies_mcp_bash_tool() {
1034 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1035 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1036
1037 let result = gate.execute_tool_call(&make_call("shell_bash")).await;
1038 assert_matches!(result, Err(ToolError::Blocked { .. }));
1039 }
1040
1041 #[tokio::test]
1042 async fn quarantined_denies_mcp_memory_save() {
1043 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1044 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1045
1046 let result = gate
1047 .execute_tool_call(&make_call("server_memory_save"))
1048 .await;
1049 assert_matches!(result, Err(ToolError::Blocked { .. }));
1050 }
1051
1052 #[tokio::test]
1053 async fn quarantined_denies_mcp_confirmed_path() {
1054 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1056 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1057
1058 let result = gate
1059 .execute_tool_call_confirmed(&make_call("filesystem_write"))
1060 .await;
1061 assert_matches!(result, Err(ToolError::Blocked { .. }));
1062 }
1063
1064 fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
1067 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1068 let handle = gate.mcp_tool_ids_handle();
1069 let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
1070 *handle.write() = set;
1071 gate
1072 }
1073
1074 #[tokio::test]
1075 async fn quarantined_denies_registered_mcp_tool_novel_name() {
1076 let gate = gate_with_mcp_ids(&["github_run_command"]);
1078 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1079
1080 let result = gate
1081 .execute_tool_call(&make_call("github_run_command"))
1082 .await;
1083 assert_matches!(result, Err(ToolError::Blocked { .. }));
1084 }
1085
1086 #[tokio::test]
1087 async fn quarantined_denies_registered_mcp_tool_execute() {
1088 let gate = gate_with_mcp_ids(&["shell_execute"]);
1090 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1091
1092 let result = gate.execute_tool_call(&make_call("shell_execute")).await;
1093 assert_matches!(result, Err(ToolError::Blocked { .. }));
1094 }
1095
1096 #[tokio::test]
1097 async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
1098 let gate = gate_with_mcp_ids(&["other_tool"]);
1100 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1101
1102 let result = gate.execute_tool_call(&make_call("read")).await;
1103 assert!(result.is_ok());
1104 }
1105
1106 #[tokio::test]
1107 async fn trusted_allows_registered_mcp_tool() {
1108 let gate = gate_with_mcp_ids(&["github_run_command"]);
1110 gate.set_effective_trust(SkillTrustLevel::Trusted);
1111
1112 let result = gate
1113 .execute_tool_call(&make_call("github_run_command"))
1114 .await;
1115 assert!(result.is_ok());
1116 }
1117
1118 #[tokio::test]
1119 async fn quarantined_denies_mcp_tool_via_confirmed_path() {
1120 let gate = gate_with_mcp_ids(&["docker_container_exec"]);
1122 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1123
1124 let result = gate
1125 .execute_tool_call_confirmed(&make_call("docker_container_exec"))
1126 .await;
1127 assert_matches!(result, Err(ToolError::Blocked { .. }));
1128 }
1129
1130 #[test]
1131 fn mcp_tool_ids_handle_shared_arc() {
1132 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1133 let handle = gate.mcp_tool_ids_handle();
1134 handle.write().insert("test_tool".to_owned());
1135 assert!(gate.is_mcp_tool("test_tool"));
1136 assert!(!gate.is_mcp_tool("other_tool"));
1137 }
1138
1139 #[test]
1142 fn invoke_skill_and_load_skill_suffix_match_is_intentional() {
1143 assert!(is_quarantine_denied("invoke_skill"));
1145 assert!(is_quarantine_denied("load_skill"));
1146 assert!(is_quarantine_denied("foo_invoke_skill"));
1149 assert!(is_quarantine_denied("foo_load_skill"));
1150 }
1151}