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