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 set_effective_trust(&self, level: crate::SkillTrustLevel) {
295 self.effective_trust
296 .store(trust_to_u8(level), Ordering::Relaxed);
297 }
298
299 fn requires_confirmation(&self, call: &crate::executor::ToolCall) -> bool {
305 let input = call
306 .params
307 .get("command")
308 .or_else(|| call.params.get("file_path"))
309 .or_else(|| call.params.get("query"))
310 .or_else(|| call.params.get("url"))
311 .or_else(|| call.params.get("uri"))
312 .and_then(|v| v.as_str())
313 .unwrap_or("");
314 matches!(
315 self.check_trust(
316 call.tool_id.as_str(),
317 input,
318 call.skill_name.as_deref().unwrap_or(&[]),
319 ),
320 Err(ToolError::ConfirmationRequired { .. })
321 )
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::assert_matches;
329
330 #[derive(Debug)]
331 struct MockExecutor;
332 impl ToolExecutor for MockExecutor {
333 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
334 Ok(None)
335 }
336 async fn execute_tool_call(
337 &self,
338 call: &ToolCall,
339 ) -> Result<Option<ToolOutput>, ToolError> {
340 Ok(Some(ToolOutput {
341 tool_name: call.tool_id.clone(),
342 summary: "ok".into(),
343 blocks_executed: 1,
344 filter_stats: None,
345 diff: None,
346 streamed: false,
347 terminal_id: None,
348 locations: None,
349 raw_response: None,
350 claim_source: None,
351 }))
352 }
353 }
354
355 fn make_call(tool_id: &str) -> ToolCall {
356 ToolCall {
357 tool_id: tool_id.into(),
358 params: serde_json::Map::new(),
359 caller_id: None,
360 context: None,
361
362 tool_call_id: String::new(),
363 skill_name: None,
364 }
365 }
366
367 fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
368 let mut params = serde_json::Map::new();
369 params.insert("command".into(), serde_json::Value::String(cmd.into()));
370 ToolCall {
371 tool_id: tool_id.into(),
372 params,
373 caller_id: None,
374 context: None,
375
376 tool_call_id: String::new(),
377 skill_name: None,
378 }
379 }
380
381 fn make_call_with_skills(tool_id: &str, skills: &[&str]) -> ToolCall {
382 ToolCall {
383 tool_id: tool_id.into(),
384 params: serde_json::Map::new(),
385 caller_id: None,
386 context: None,
387
388 tool_call_id: String::new(),
389 skill_name: Some(skills.iter().map(ToString::to_string).collect()),
390 }
391 }
392
393 fn blocked_command(result: Result<Option<ToolOutput>, ToolError>) -> String {
394 match result {
395 Err(ToolError::Blocked { command }) => command,
396 other => panic!("expected Err(ToolError::Blocked {{ .. }}), got {other:?}"),
397 }
398 }
399
400 #[tokio::test]
401 async fn supervised_readonly_native_tool_without_rule_allowed() {
402 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
405 gate.set_effective_trust(SkillTrustLevel::Trusted);
406
407 let result = gate.execute_tool_call(&make_call("read")).await;
408 assert!(result.is_ok());
409 }
410
411 #[tokio::test]
415 async fn supervised_unconfigured_non_mcp_non_readonly_tool_requires_confirmation() {
416 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
417 gate.set_effective_trust(SkillTrustLevel::Trusted);
418
419 let result = gate.execute_tool_call(&make_call("bash")).await;
420 assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
421 }
422
423 #[tokio::test]
428 async fn supervised_unconfigured_diagnostics_requires_confirmation() {
429 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
430 gate.set_effective_trust(SkillTrustLevel::Trusted);
431
432 let result = gate.execute_tool_call(&make_call("diagnostics")).await;
433 assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
434 }
435
436 #[tokio::test]
437 async fn quarantined_denies_bash() {
438 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
439 gate.set_effective_trust(SkillTrustLevel::Quarantined);
440
441 let result = gate.execute_tool_call(&make_call("bash")).await;
442 assert_matches!(result, Err(ToolError::Blocked { .. }));
443 }
444
445 #[tokio::test]
446 async fn quarantined_denies_write() {
447 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
448 gate.set_effective_trust(SkillTrustLevel::Quarantined);
449
450 let result = gate.execute_tool_call(&make_call("write")).await;
451 assert_matches!(result, Err(ToolError::Blocked { .. }));
452 }
453
454 #[tokio::test]
455 async fn quarantined_denies_edit() {
456 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
457 gate.set_effective_trust(SkillTrustLevel::Quarantined);
458
459 let result = gate.execute_tool_call(&make_call("edit")).await;
460 assert_matches!(result, Err(ToolError::Blocked { .. }));
461 }
462
463 #[tokio::test]
464 async fn quarantined_denies_delete_path() {
465 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
466 gate.set_effective_trust(SkillTrustLevel::Quarantined);
467
468 let result = gate.execute_tool_call(&make_call("delete_path")).await;
469 assert_matches!(result, Err(ToolError::Blocked { .. }));
470 }
471
472 #[tokio::test]
473 async fn quarantined_denies_fetch() {
474 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
475 gate.set_effective_trust(SkillTrustLevel::Quarantined);
476
477 let result = gate.execute_tool_call(&make_call("fetch")).await;
478 assert_matches!(result, Err(ToolError::Blocked { .. }));
479 }
480
481 #[tokio::test]
482 async fn quarantined_denies_memory_save() {
483 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
484 gate.set_effective_trust(SkillTrustLevel::Quarantined);
485
486 let result = gate.execute_tool_call(&make_call("memory_save")).await;
487 assert_matches!(result, Err(ToolError::Blocked { .. }));
488 }
489
490 #[tokio::test]
496 async fn quarantined_denies_diagnostics() {
497 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
498 gate.set_effective_trust(SkillTrustLevel::Quarantined);
499
500 let result = gate.execute_tool_call(&make_call("diagnostics")).await;
501 assert_matches!(result, Err(ToolError::Blocked { .. }));
502 }
503
504 #[tokio::test]
505 async fn quarantined_allows_read() {
506 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
507 let gate = TrustGateExecutor::new(MockExecutor, policy);
508 gate.set_effective_trust(SkillTrustLevel::Quarantined);
509
510 let result = gate.execute_tool_call(&make_call("read")).await;
512 assert!(result.is_ok());
513 }
514
515 #[tokio::test]
516 async fn quarantined_allows_file_read() {
517 let mut rules = std::collections::HashMap::new();
522 rules.insert(
523 "file_read".to_owned(),
524 vec![crate::permissions::PermissionRule {
525 pattern: "*".to_owned(),
526 action: PermissionAction::Allow,
527 }],
528 );
529 let policy = crate::permissions::PermissionPolicy::new(rules);
530 let gate = TrustGateExecutor::new(MockExecutor, policy);
531 gate.set_effective_trust(SkillTrustLevel::Quarantined);
532
533 let result = gate.execute_tool_call(&make_call("file_read")).await;
534 assert!(result.is_ok());
536 }
537
538 #[tokio::test]
539 async fn blocked_denies_everything() {
540 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
541 gate.set_effective_trust(SkillTrustLevel::Blocked);
542
543 let result = gate.execute_tool_call(&make_call("file_read")).await;
544 assert_matches!(result, Err(ToolError::Blocked { .. }));
545 }
546
547 #[tokio::test]
548 async fn policy_deny_overrides_trust() {
549 let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
550 let gate = TrustGateExecutor::new(MockExecutor, policy);
551 gate.set_effective_trust(SkillTrustLevel::Trusted);
552
553 let result = gate
554 .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
555 .await;
556 assert_matches!(result, Err(ToolError::Blocked { .. }));
557 }
558
559 #[tokio::test]
560 async fn blocked_denies_execute() {
561 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
562 gate.set_effective_trust(SkillTrustLevel::Blocked);
563
564 let result = gate.execute("some response").await;
565 assert_matches!(result, Err(ToolError::Blocked { .. }));
566 }
567
568 #[tokio::test]
569 async fn blocked_denies_execute_confirmed() {
570 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
571 gate.set_effective_trust(SkillTrustLevel::Blocked);
572
573 let result = gate.execute_confirmed("some response").await;
574 assert_matches!(result, Err(ToolError::Blocked { .. }));
575 }
576
577 #[tokio::test]
578 async fn trusted_allows_execute() {
579 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
580 gate.set_effective_trust(SkillTrustLevel::Trusted);
581
582 let result = gate.execute("some response").await;
583 assert!(result.is_ok());
584 }
585
586 #[tokio::test]
587 async fn verified_with_allow_policy_succeeds() {
588 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
589 let gate = TrustGateExecutor::new(MockExecutor, policy);
590 gate.set_effective_trust(SkillTrustLevel::Verified);
591
592 let result = gate
593 .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
594 .await
595 .unwrap();
596 assert!(result.is_some());
597 }
598
599 #[tokio::test]
600 async fn quarantined_denies_web_scrape() {
601 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
602 gate.set_effective_trust(SkillTrustLevel::Quarantined);
603
604 let result = gate.execute_tool_call(&make_call("web_scrape")).await;
605 assert_matches!(result, Err(ToolError::Blocked { .. }));
606 }
607
608 #[tokio::test]
612 async fn quarantined_denial_message_names_active_skills_not_target_tool() {
613 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
614 gate.set_effective_trust(SkillTrustLevel::Quarantined);
615
616 let call =
617 make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
618 let result = gate.execute_tool_call(&call).await;
619 let message = blocked_command(result);
620
621 assert!(
622 message.contains("disk-usage") && message.contains("persona-customer-support"),
623 "message should name the actual active skills, got: {message}"
624 );
625 assert_ne!(
626 message, "invoke_skill denied (trust=quarantined)",
627 "message must not read as if invoke_skill itself is the untrusted party"
628 );
629 }
630
631 #[tokio::test]
636 async fn quarantined_denial_message_unchanged_when_no_active_skills() {
637 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
638 gate.set_effective_trust(SkillTrustLevel::Quarantined);
639
640 let result = gate.execute_tool_call(&make_call("invoke_skill")).await;
641 let message = blocked_command(result);
642
643 assert_eq!(message, "invoke_skill denied (trust=quarantined)");
644 }
645
646 #[tokio::test]
649 async fn quarantined_denial_message_unchanged_when_active_skills_empty() {
650 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
651 gate.set_effective_trust(SkillTrustLevel::Quarantined);
652
653 let result = gate
654 .execute_tool_call(&make_call_with_skills("invoke_skill", &[]))
655 .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_names_active_skills_confirmed_path() {
665 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
666 gate.set_effective_trust(SkillTrustLevel::Quarantined);
667
668 let call =
669 make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
670 let result = gate.execute_tool_call_confirmed(&call).await;
671 let message = blocked_command(result);
672
673 assert!(
674 message.contains("disk-usage") && message.contains("persona-customer-support"),
675 "confirmed path message should name the actual active skills, got: {message}"
676 );
677 assert_ne!(
678 message, "invoke_skill denied (trust=quarantined)",
679 "confirmed path message must not read as if invoke_skill itself is untrusted"
680 );
681 }
682
683 #[tokio::test]
686 async fn quarantined_denial_message_names_active_skills_for_non_skill_tool() {
687 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
688 gate.set_effective_trust(SkillTrustLevel::Quarantined);
689
690 let call = make_call_with_skills("bash", &["disk-usage", "persona-customer-support"]);
691 let result = gate.execute_tool_call(&call).await;
692 let message = blocked_command(result);
693
694 assert!(
695 message.contains("disk-usage") && message.contains("persona-customer-support"),
696 "message for a non-skill tool should also name the active skills, got: {message}"
697 );
698 assert_ne!(message, "bash denied (trust=quarantined)");
699 }
700
701 #[derive(Debug)]
702 struct EnvCapture {
703 captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
704 }
705 impl EnvCapture {
706 fn new() -> Self {
707 Self {
708 captured: std::sync::Mutex::new(None),
709 }
710 }
711 }
712 impl ToolExecutor for EnvCapture {
713 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
714 Ok(None)
715 }
716 async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
717 Ok(None)
718 }
719 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
720 *self.captured.lock().unwrap() = env;
721 }
722 }
723
724 #[test]
725 fn is_tool_retryable_delegated_to_inner() {
726 #[derive(Debug)]
727 struct RetryableExecutor;
728 impl ToolExecutor for RetryableExecutor {
729 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
730 Ok(None)
731 }
732 async fn execute_tool_call(
733 &self,
734 _: &ToolCall,
735 ) -> Result<Option<ToolOutput>, ToolError> {
736 Ok(None)
737 }
738 fn is_tool_retryable(&self, tool_id: &str) -> bool {
739 tool_id == "fetch"
740 }
741 }
742 let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
743 assert!(gate.is_tool_retryable("fetch"));
744 assert!(!gate.is_tool_retryable("bash"));
745 }
746
747 #[test]
748 fn set_skill_env_forwarded_to_inner() {
749 let inner = EnvCapture::new();
750 let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
751
752 let mut env = std::collections::HashMap::new();
753 env.insert("MY_VAR".to_owned(), "42".to_owned());
754 gate.set_skill_env(Some(env.clone()));
755
756 let captured = gate.inner.captured.lock().unwrap();
757 assert_eq!(*captured, Some(env));
758 }
759
760 #[tokio::test]
761 async fn mcp_tool_supervised_no_rules_allows() {
762 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
766 let gate = TrustGateExecutor::new(MockExecutor, policy);
767 gate.set_effective_trust(SkillTrustLevel::Trusted);
768 gate.mcp_tool_ids_handle()
769 .write()
770 .insert("mcp_filesystem__read_file".to_owned());
771
772 let mut params = serde_json::Map::new();
773 params.insert(
774 "file_path".into(),
775 serde_json::Value::String("/tmp/test.txt".into()),
776 );
777 let call = ToolCall {
778 tool_id: "mcp_filesystem__read_file".into(),
779 params,
780 caller_id: None,
781 context: None,
782
783 tool_call_id: String::new(),
784 skill_name: None,
785 };
786 let result = gate.execute_tool_call(&call).await;
787 assert!(
788 result.is_ok(),
789 "MCP tool should be allowed when no rules exist"
790 );
791 }
792
793 #[tokio::test]
794 async fn bash_with_explicit_deny_rule_blocked() {
795 let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
797 let gate = TrustGateExecutor::new(MockExecutor, policy);
798 gate.set_effective_trust(SkillTrustLevel::Trusted);
799
800 let result = gate
801 .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
802 .await;
803 assert!(
804 matches!(result, Err(ToolError::Blocked { .. })),
805 "bash with explicit deny rule should be blocked"
806 );
807 }
808
809 #[tokio::test]
810 async fn bash_with_explicit_allow_rule_succeeds() {
811 let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
813 let gate = TrustGateExecutor::new(MockExecutor, policy);
814 gate.set_effective_trust(SkillTrustLevel::Trusted);
815
816 let result = gate
817 .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
818 .await;
819 assert!(
820 result.is_ok(),
821 "bash with explicit allow rule should succeed"
822 );
823 }
824
825 #[tokio::test]
826 async fn readonly_denies_mcp_tool_not_in_allowlist() {
827 let policy =
829 crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
830 let gate = TrustGateExecutor::new(MockExecutor, policy);
831 gate.set_effective_trust(SkillTrustLevel::Trusted);
832
833 let result = gate
834 .execute_tool_call(&make_call("mcpls_get_diagnostics"))
835 .await;
836 assert!(
837 matches!(result, Err(ToolError::Blocked { .. })),
838 "ReadOnly mode must deny non-allowlisted tools"
839 );
840 }
841
842 #[test]
843 fn set_effective_trust_interior_mutability() {
844 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
845 assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
846
847 gate.set_effective_trust(SkillTrustLevel::Quarantined);
848 assert_eq!(gate.effective_trust(), SkillTrustLevel::Quarantined);
849
850 gate.set_effective_trust(SkillTrustLevel::Blocked);
851 assert_eq!(gate.effective_trust(), SkillTrustLevel::Blocked);
852
853 gate.set_effective_trust(SkillTrustLevel::Trusted);
854 assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
855 }
856
857 #[test]
860 fn is_quarantine_denied_exact_match() {
861 assert!(is_quarantine_denied("bash"));
862 assert!(is_quarantine_denied("write"));
863 assert!(is_quarantine_denied("fetch"));
864 assert!(is_quarantine_denied("memory_save"));
865 assert!(is_quarantine_denied("delete_path"));
866 assert!(is_quarantine_denied("create_directory"));
867 assert!(is_quarantine_denied("diagnostics"));
868 }
869
870 #[test]
871 fn is_quarantine_denied_suffix_match_mcp_write() {
872 assert!(is_quarantine_denied("filesystem_write"));
874 assert!(!is_quarantine_denied("filesystem_write_file"));
876 }
877
878 #[test]
879 fn is_quarantine_denied_suffix_mcp_bash() {
880 assert!(is_quarantine_denied("shell_bash"));
881 assert!(is_quarantine_denied("mcp_shell_bash"));
882 }
883
884 #[test]
885 fn is_quarantine_denied_suffix_mcp_fetch() {
886 assert!(is_quarantine_denied("http_fetch"));
887 assert!(!is_quarantine_denied("server_prefetch"));
889 }
890
891 #[test]
892 fn is_quarantine_denied_suffix_mcp_memory_save() {
893 assert!(is_quarantine_denied("server_memory_save"));
894 assert!(!is_quarantine_denied("server_save"));
896 }
897
898 #[test]
899 fn is_quarantine_denied_suffix_mcp_delete_path() {
900 assert!(is_quarantine_denied("fs_delete_path"));
901 assert!(is_quarantine_denied("fs_not_delete_path"));
903 }
904
905 #[test]
906 fn is_quarantine_denied_substring_not_suffix() {
907 assert!(!is_quarantine_denied("write_log"));
909 }
910
911 #[test]
912 fn is_quarantine_denied_read_only_tools_allowed() {
913 assert!(!is_quarantine_denied("filesystem_read_file"));
914 assert!(!is_quarantine_denied("filesystem_list_dir"));
915 assert!(!is_quarantine_denied("read"));
916 assert!(!is_quarantine_denied("file_read"));
917 }
918
919 #[tokio::test]
920 async fn quarantined_denies_mcp_write_tool() {
921 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
922 gate.set_effective_trust(SkillTrustLevel::Quarantined);
923
924 let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
925 assert_matches!(result, Err(ToolError::Blocked { .. }));
926 }
927
928 #[tokio::test]
929 async fn quarantined_allows_mcp_read_file() {
930 let mut rules = std::collections::HashMap::new();
937 rules.insert(
938 "filesystem_read_file".to_owned(),
939 vec![crate::permissions::PermissionRule {
940 pattern: "*".to_owned(),
941 action: PermissionAction::Allow,
942 }],
943 );
944 let policy = crate::permissions::PermissionPolicy::new(rules);
945 let gate = TrustGateExecutor::new(MockExecutor, policy);
946 gate.set_effective_trust(SkillTrustLevel::Quarantined);
947
948 let result = gate
949 .execute_tool_call(&make_call("filesystem_read_file"))
950 .await;
951 assert!(result.is_ok());
952 }
953
954 #[tokio::test]
955 async fn quarantined_denies_mcp_bash_tool() {
956 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
957 gate.set_effective_trust(SkillTrustLevel::Quarantined);
958
959 let result = gate.execute_tool_call(&make_call("shell_bash")).await;
960 assert_matches!(result, Err(ToolError::Blocked { .. }));
961 }
962
963 #[tokio::test]
964 async fn quarantined_denies_mcp_memory_save() {
965 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
966 gate.set_effective_trust(SkillTrustLevel::Quarantined);
967
968 let result = gate
969 .execute_tool_call(&make_call("server_memory_save"))
970 .await;
971 assert_matches!(result, Err(ToolError::Blocked { .. }));
972 }
973
974 #[tokio::test]
975 async fn quarantined_denies_mcp_confirmed_path() {
976 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
978 gate.set_effective_trust(SkillTrustLevel::Quarantined);
979
980 let result = gate
981 .execute_tool_call_confirmed(&make_call("filesystem_write"))
982 .await;
983 assert_matches!(result, Err(ToolError::Blocked { .. }));
984 }
985
986 fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
989 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
990 let handle = gate.mcp_tool_ids_handle();
991 let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
992 *handle.write() = set;
993 gate
994 }
995
996 #[tokio::test]
997 async fn quarantined_denies_registered_mcp_tool_novel_name() {
998 let gate = gate_with_mcp_ids(&["github_run_command"]);
1000 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1001
1002 let result = gate
1003 .execute_tool_call(&make_call("github_run_command"))
1004 .await;
1005 assert_matches!(result, Err(ToolError::Blocked { .. }));
1006 }
1007
1008 #[tokio::test]
1009 async fn quarantined_denies_registered_mcp_tool_execute() {
1010 let gate = gate_with_mcp_ids(&["shell_execute"]);
1012 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1013
1014 let result = gate.execute_tool_call(&make_call("shell_execute")).await;
1015 assert_matches!(result, Err(ToolError::Blocked { .. }));
1016 }
1017
1018 #[tokio::test]
1019 async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
1020 let gate = gate_with_mcp_ids(&["other_tool"]);
1022 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1023
1024 let result = gate.execute_tool_call(&make_call("read")).await;
1025 assert!(result.is_ok());
1026 }
1027
1028 #[tokio::test]
1029 async fn trusted_allows_registered_mcp_tool() {
1030 let gate = gate_with_mcp_ids(&["github_run_command"]);
1032 gate.set_effective_trust(SkillTrustLevel::Trusted);
1033
1034 let result = gate
1035 .execute_tool_call(&make_call("github_run_command"))
1036 .await;
1037 assert!(result.is_ok());
1038 }
1039
1040 #[tokio::test]
1041 async fn quarantined_denies_mcp_tool_via_confirmed_path() {
1042 let gate = gate_with_mcp_ids(&["docker_container_exec"]);
1044 gate.set_effective_trust(SkillTrustLevel::Quarantined);
1045
1046 let result = gate
1047 .execute_tool_call_confirmed(&make_call("docker_container_exec"))
1048 .await;
1049 assert_matches!(result, Err(ToolError::Blocked { .. }));
1050 }
1051
1052 #[test]
1053 fn mcp_tool_ids_handle_shared_arc() {
1054 let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1055 let handle = gate.mcp_tool_ids_handle();
1056 handle.write().insert("test_tool".to_owned());
1057 assert!(gate.is_mcp_tool("test_tool"));
1058 assert!(!gate.is_mcp_tool("other_tool"));
1059 }
1060
1061 #[test]
1064 fn invoke_skill_and_load_skill_suffix_match_is_intentional() {
1065 assert!(is_quarantine_denied("invoke_skill"));
1067 assert!(is_quarantine_denied("load_skill"));
1068 assert!(is_quarantine_denied("foo_invoke_skill"));
1071 assert!(is_quarantine_denied("foo_load_skill"));
1072 }
1073}