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