1use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use parking_lot::Mutex;
21
22use crate::access_manager::audit_sink::{AuditEvent, AuditSink};
23use crate::access_manager::context::AgentContext;
24use crate::access_manager::{AccessManager, Action, Subject};
25use crate::capability::{ResourceRef, Rights};
26use crate::config::ExecConfig;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PathMode {
33 Read,
35 Write,
37}
38
39impl std::fmt::Display for PathMode {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 PathMode::Read => write!(f, "read"),
43 PathMode::Write => write!(f, "write"),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum DenyLayer {
53 Capability,
55 Rbac,
57 Permission,
59 ExecPolicy,
61}
62
63impl std::fmt::Display for DenyLayer {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 DenyLayer::Capability => write!(f, "CSpace"),
67 DenyLayer::Rbac => write!(f, "RBAC"),
68 DenyLayer::Permission => write!(f, "Permissions"),
69 DenyLayer::ExecPolicy => write!(f, "ExecPolicy"),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
78pub struct AccessDenied {
79 pub agent: String,
81 pub resource: String,
83 pub layer: DenyLayer,
85 pub reason: String,
87 pub suggestion: Option<String>,
89}
90
91impl std::fmt::Display for AccessDenied {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 write!(
94 f,
95 "[{}] {} — {}",
96 self.layer,
97 self.reason,
98 self.suggestion.as_deref().unwrap_or("")
99 )
100 }
101}
102
103#[derive(Debug)]
107pub enum CheckRequest<'a> {
108 Tool {
110 context: &'a AgentContext,
112 tool_name: &'a str,
114 },
115 Path {
117 context: &'a AgentContext,
119 path: &'a Path,
121 mode: PathMode,
123 },
124 Exec {
126 context: &'a AgentContext,
128 binary: &'a str,
130 args: &'a [String],
132 },
133 Network {
135 context: &'a AgentContext,
137 },
138 Fork {
140 context: &'a AgentContext,
142 },
143}
144
145impl<'a> CheckRequest<'a> {
146 pub fn agent_context(&self) -> &AgentContext {
148 match self {
149 CheckRequest::Tool { context, .. } => context,
150 CheckRequest::Path { context, .. } => context,
151 CheckRequest::Exec { context, .. } => context,
152 CheckRequest::Network { context } => context,
153 CheckRequest::Fork { context } => context,
154 }
155 }
156
157 pub fn resource(&self) -> &str {
159 match self {
160 CheckRequest::Tool { tool_name, .. } => tool_name,
161 CheckRequest::Path { path, .. } => path.to_str().unwrap_or("<invalid-path>"),
162 CheckRequest::Exec { binary, .. } => binary,
163 CheckRequest::Network { .. } => "<network>",
164 CheckRequest::Fork { .. } => "fork",
165 }
166 }
167}
168
169const SHELL_METACHARS: &[char] = &[
173 '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
174];
175
176fn has_metacharacters(args: &[String]) -> bool {
178 for arg in args {
179 if arg.contains("..") {
180 return true;
181 }
182 if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
183 return true;
184 }
185 }
186 false
187}
188
189fn canonicalize_for_check(path: &Path) -> PathBuf {
200 if let Ok(canon) = path.canonicalize() {
201 return canon;
202 }
203 let mut ancestor = path.to_path_buf();
204 let mut tail: Vec<OsString> = Vec::new();
205 while !ancestor.exists() {
206 match ancestor.file_name() {
207 Some(name) => {
208 tail.push(name.to_os_string());
209 if !ancestor.pop() {
210 break;
211 }
212 }
213 None => break,
214 }
215 }
216 match ancestor.canonicalize() {
217 Ok(mut base) => {
218 for name in tail.into_iter().rev() {
219 base.push(name);
220 }
221 base
222 }
223 Err(_) => path.to_path_buf(),
224 }
225}
226
227pub struct AccessGate {
251 access: Arc<Mutex<AccessManager>>,
253 exec_config: Arc<ExecConfig>,
255 audit: Arc<dyn AuditSink>,
257}
258
259impl AccessGate {
260 pub fn new(
262 access: Arc<Mutex<AccessManager>>,
263 exec_config: Arc<ExecConfig>,
264 audit: Arc<dyn AuditSink>,
265 ) -> Self {
266 Self {
267 access,
268 exec_config,
269 audit,
270 }
271 }
272
273 pub fn access_clone(&self) -> Arc<Mutex<AccessManager>> {
275 self.access.clone()
276 }
277
278 pub fn check(&self, req: CheckRequest<'_>) -> Result<(), AccessDenied> {
284 let result = match &req {
285 CheckRequest::Tool { context, tool_name } => self.check_tool(context, tool_name),
286 CheckRequest::Path {
287 context,
288 path,
289 mode,
290 } => self.check_path(context, path, *mode),
291 CheckRequest::Exec {
292 context,
293 binary,
294 args,
295 } => self.check_exec(context, binary, args),
296 CheckRequest::Network { context } => self.check_network(context),
297 CheckRequest::Fork { context } => self.check_fork(context),
298 };
299
300 self.record_check(&req, &result);
302
303 result
304 }
305
306 fn check_tool(&self, ctx: &AgentContext, tool: &str) -> Result<(), AccessDenied> {
309 let resource = ResourceRef::KernelDomain {
310 domain: tool.to_string(),
311 };
312 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
313 let always_on = [
334 "read",
335 "write",
336 "edit",
337 "grep",
338 "find",
339 "ls",
340 "web_search",
341 "get_search_results",
342 ];
343 if !always_on.contains(&tool) {
344 return Err(AccessDenied {
345 agent: ctx.agent_name.clone(),
346 resource: tool.to_string(),
347 layer: DenyLayer::Capability,
348 reason: format!("CSpace lacks EXECUTE capability for tool '{tool}'"),
349 suggestion: Some(format!("Add the '{tool}' capability to the agent's Seed.")),
350 });
351 }
352 }
353
354 let mut access = self.access.lock();
356 if !access.can_use_tool(&ctx.agent_name, tool) {
357 return Err(AccessDenied {
358 agent: ctx.agent_name.clone(),
359 resource: tool.to_string(),
360 layer: DenyLayer::Permission,
361 reason: format!(
362 "'{}' is not in agent '{}'s allowed_tools",
363 tool, ctx.agent_name
364 ),
365 suggestion: Some(format!(
366 "Request permission for the '{}' tool on agent '{}' from your administrator.",
367 tool, ctx.agent_name
368 )),
369 });
370 }
371
372 Ok(())
373 }
374
375 fn check_path(
376 &self,
377 ctx: &AgentContext,
378 path: &Path,
379 mode: PathMode,
380 ) -> Result<(), AccessDenied> {
381 let resolved = if path.is_relative() {
387 std::env::current_dir()
388 .unwrap_or_else(|_| std::path::PathBuf::from("."))
389 .join(path)
390 } else {
391 path.to_path_buf()
392 };
393 let resolved = canonicalize_for_check(&resolved);
394 let path_str = resolved.to_string_lossy();
395
396 let resource = ResourceRef::KernelDomain {
398 domain: "fs".to_string(),
399 };
400 let required = match mode {
401 PathMode::Read => Rights::READ,
402 PathMode::Write => Rights::WRITE,
403 };
404 if !ctx.cspace.can(&resource, required) {
405 tracing::debug!(
408 agent = %ctx.agent_name,
409 mode = %mode,
410 "CSpace does not contain fs capability, proceeding (advisory)"
411 );
412 }
413
414 let mut access = self.access.lock();
416 let rbac_subject = Subject::Agent(ctx.agent_id);
417 let rbac_action = Action::AccessPath(path_str.to_string());
418 if !access
419 .rbac_manager_mut()
420 .check_permission(&rbac_subject, &rbac_action, &path_str)
421 {
422 return Err(AccessDenied {
423 agent: ctx.agent_name.clone(),
424 resource: path_str.to_string(),
425 layer: DenyLayer::Rbac,
426 reason: "RBAC policy denies access to this path".into(),
427 suggestion: Some("Review the RBAC policy.".into()),
428 });
429 }
430
431 if !access.can_access_path(&ctx.agent_name, &path_str) {
433 return Err(AccessDenied {
434 agent: ctx.agent_name.clone(),
435 resource: path_str.to_string(),
436 layer: DenyLayer::Permission,
437 reason: format!("Path '{path_str}' is not in allowed_paths or is in denied_paths"),
438 suggestion: Some("Review the allowed_paths / denied_paths settings.".into()),
439 });
440 }
441
442 if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
444 && !access.is_path_in_workspace(&ws, &path_str)
445 {
446 self.audit.record(AuditEvent::SandboxViolation {
448 timestamp: chrono::Utc::now(),
449 agent: ctx.agent_name.clone(),
450 path: path_str.to_string(),
451 workspace: ws.clone(),
452 });
453 return Err(AccessDenied {
454 agent: ctx.agent_name.clone(),
455 resource: path_str.to_string(),
456 layer: DenyLayer::Permission,
457 reason: format!("Path '{path_str}' is outside the '{ws}' workspace boundary"),
458 suggestion: None,
459 });
460 }
461
462 Ok(())
463 }
464
465 fn check_exec(
466 &self,
467 ctx: &AgentContext,
468 binary: &str,
469 args: &[String],
470 ) -> Result<(), AccessDenied> {
471 let resource = ResourceRef::Exec {
473 mode: "structured".to_string(),
474 };
475 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
476 let shell_resource = ResourceRef::Exec {
478 mode: "shell".to_string(),
479 };
480 if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
481 && !ctx.cspace.can(&resource, Rights::EXECUTE)
482 {
483 return Err(AccessDenied {
484 agent: ctx.agent_name.clone(),
485 resource: binary.to_string(),
486 layer: DenyLayer::Capability,
487 reason: "CSpace lacks Exec capability".into(),
488 suggestion: Some("Add the Exec capability to the Seed.".into()),
489 });
490 }
491 }
492
493 let mut access = self.access.lock();
497 if !access.can_use_tool(&ctx.agent_name, "exec") {
498 return Err(AccessDenied {
499 agent: ctx.agent_name.clone(),
500 resource: binary.to_string(),
501 layer: DenyLayer::Permission,
502 reason: format!("Agent lacks permission to execute '{binary}'"),
503 suggestion: None,
504 });
505 }
506
507 if !self.exec_config.is_binary_allowed(binary) {
509 return Err(AccessDenied {
510 agent: ctx.agent_name.clone(),
511 resource: binary.to_string(),
512 layer: DenyLayer::ExecPolicy,
513 reason: format!("Binary '{binary}' is not in the allowlist"),
514 suggestion: Some("Add it to exec.allowed_commands.".into()),
515 });
516 }
517
518 if has_metacharacters(args) {
520 return Err(AccessDenied {
521 agent: ctx.agent_name.clone(),
522 resource: binary.to_string(),
523 layer: DenyLayer::ExecPolicy,
524 reason: "Arguments contain shell metacharacters or path traversal patterns".into(),
525 suggestion: None,
526 });
527 }
528
529 Ok(())
530 }
531
532 fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
533 let mut access = self.access.lock();
534 if !access.can_access_network(&ctx.agent_name) {
535 return Err(AccessDenied {
536 agent: ctx.agent_name.clone(),
537 resource: "<network>".into(),
538 layer: DenyLayer::Permission,
539 reason: "Network access is disabled".into(),
540 suggestion: Some("Set permissions.network_access to true.".into()),
541 });
542 }
543 Ok(())
544 }
545
546 fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
547 let resource = ResourceRef::KernelDomain {
549 domain: "agent".to_string(),
550 };
551 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
552 return Err(AccessDenied {
553 agent: ctx.agent_name.clone(),
554 resource: "fork".into(),
555 layer: DenyLayer::Capability,
556 reason: "CSpace lacks agent-management capability".into(),
557 suggestion: None,
558 });
559 }
560
561 let access = self.access.lock();
563 if !access.can_fork(&ctx.agent_name) {
564 return Err(AccessDenied {
565 agent: ctx.agent_name.clone(),
566 resource: "fork".into(),
567 layer: DenyLayer::Permission,
568 reason: "Agent lacks fork permission".into(),
569 suggestion: Some("Set permissions.can_fork to true.".into()),
570 });
571 }
572 Ok(())
573 }
574
575 fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
578 let event = match result {
579 Ok(()) => self.allowed_event(req),
580 Err(denied) => self.denied_event(req, denied),
581 };
582 self.audit.record(event);
583 }
584
585 fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
586 let ctx = req.agent_context();
587 let ts = chrono::Utc::now();
588 match req {
589 CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
590 timestamp: ts,
591 agent: ctx.agent_name.clone(),
592 tool: tool_name.to_string(),
593 allowed: true,
594 layer: None,
595 reason: None,
596 },
597 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
598 timestamp: ts,
599 agent: ctx.agent_name.clone(),
600 path: path.to_string_lossy().to_string(),
601 mode: mode.to_string(),
602 allowed: true,
603 layer: None,
604 reason: None,
605 },
606 CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
607 timestamp: ts,
608 agent: ctx.agent_name.clone(),
609 binary: binary.to_string(),
610 allowed: true,
611 layer: None,
612 reason: None,
613 },
614 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
615 timestamp: ts,
616 agent: ctx.agent_name.clone(),
617 tool: "network".into(),
618 allowed: true,
619 layer: None,
620 reason: None,
621 },
622 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
623 timestamp: ts,
624 agent: ctx.agent_name.clone(),
625 tool: "fork".into(),
626 allowed: true,
627 layer: None,
628 reason: None,
629 },
630 }
631 }
632
633 fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
634 let ctx = req.agent_context();
635 let ts = chrono::Utc::now();
636 let layer = Some(denied.layer.to_string());
637 let reason = Some(denied.reason.clone());
638
639 match req {
640 CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
641 timestamp: ts,
642 agent: ctx.agent_name.clone(),
643 tool: denied.resource.clone(),
644 allowed: false,
645 layer,
646 reason,
647 },
648 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
649 timestamp: ts,
650 agent: ctx.agent_name.clone(),
651 path: path.to_string_lossy().to_string(),
652 mode: mode.to_string(),
653 allowed: false,
654 layer,
655 reason,
656 },
657 CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
658 timestamp: ts,
659 agent: ctx.agent_name.clone(),
660 binary: denied.resource.clone(),
661 allowed: false,
662 layer,
663 reason,
664 },
665 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
666 timestamp: ts,
667 agent: ctx.agent_name.clone(),
668 tool: "network".into(),
669 allowed: false,
670 layer,
671 reason,
672 },
673 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
674 timestamp: ts,
675 agent: ctx.agent_name.clone(),
676 tool: "fork".into(),
677 allowed: false,
678 layer,
679 reason,
680 },
681 }
682 }
683}
684
685impl std::fmt::Debug for AccessGate {
686 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687 f.debug_struct("AccessGate").finish()
688 }
689}
690
691#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::access_manager::AgentPermissions;
697 use crate::access_manager::audit_sink::NoOpAuditSink;
698 use crate::config::AllowlistMode;
699
700 fn make_gate() -> (AccessGate, AgentContext) {
702 let mut access = AccessManager::new();
703
704 let ctx = AgentContext::test_fixture("test-agent");
706
707 let mut perms = AgentPermissions::for_new_agent("test-agent");
709 perms.allow_path("/workspace/**");
710 perms.allow_path("/tmp/**");
711 access.set_permissions(perms);
712
713 let subject = Subject::Agent(ctx.agent_id);
715 access
716 .rbac_manager_mut()
717 .assign_role(subject, crate::access_manager::Role::Superuser);
718
719 let gate = AccessGate::new(
720 Arc::new(Mutex::new(access)),
721 Arc::new(ExecConfig {
722 allowlist_mode: AllowlistMode::Permissive, ..Default::default()
724 }),
725 Arc::new(NoOpAuditSink),
726 );
727
728 (gate, ctx)
729 }
730
731 fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
733 let mut access = AccessManager::new();
734 let ctx = AgentContext::test_fixture("test-agent");
735
736 let perms = AgentPermissions::for_new_agent("test-agent");
737 access.set_permissions(perms);
738
739 let subject = Subject::Agent(ctx.agent_id);
740 access
741 .rbac_manager_mut()
742 .assign_role(subject, crate::access_manager::Role::Superuser);
743
744 let config = ExecConfig {
745 allowlist_mode: AllowlistMode::Enforced,
746 allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
747 ..Default::default()
748 };
749
750 let gate = AccessGate::new(
751 Arc::new(Mutex::new(access)),
752 Arc::new(config),
753 Arc::new(NoOpAuditSink),
754 );
755
756 (gate, ctx)
757 }
758
759 #[test]
762 fn test_tool_access_allowed() {
763 let (gate, ctx) = make_gate();
764 let result = gate.check(CheckRequest::Tool {
765 context: &ctx,
766 tool_name: "bash",
767 });
768 assert!(result.is_ok(), "bash should be allowed: {:?}", result);
769 }
770
771 #[test]
772 fn test_tool_access_web_search_always_on() {
773 let (gate, ctx) = make_gate();
780 let result = gate.check(CheckRequest::Tool {
781 context: &ctx,
782 tool_name: "web_search",
783 });
784 assert!(result.is_ok(), "web_search is always-on: {:?}", result);
785
786 let result = gate.check(CheckRequest::Tool {
787 context: &ctx,
788 tool_name: "get_search_results",
789 });
790 assert!(
791 result.is_ok(),
792 "get_search_results is always-on: {:?}",
793 result
794 );
795 }
796
797 #[test]
798 fn test_tool_access_unknown_agent_denied() {
799 let gate = AccessGate::new(
800 Arc::new(Mutex::new(AccessManager::new())), Arc::new(ExecConfig::default()),
802 Arc::new(NoOpAuditSink),
803 );
804 let ctx = AgentContext::test_fixture("unknown");
805
806 let result = gate.check(CheckRequest::Tool {
807 context: &ctx,
808 tool_name: "exec",
809 });
810 assert!(result.is_err());
811 let err = result.unwrap_err();
812 assert_eq!(err.layer, DenyLayer::Permission);
813 }
814
815 #[test]
818 fn test_exec_allowed_permissive() {
819 let (gate, ctx) = make_gate();
820 let result = gate.check(CheckRequest::Exec {
821 context: &ctx,
822 binary: "echo",
823 args: &["hello".to_string()],
824 });
825 assert!(result.is_ok(), "echo should be allowed in permissive mode");
826 }
827
828 #[test]
829 fn test_exec_denied_enforced() {
830 let (gate, ctx) = make_enforced_gate(vec!["git"]);
831 let result = gate.check(CheckRequest::Exec {
832 context: &ctx,
833 binary: "rm",
834 args: &[],
835 });
836 assert!(result.is_err());
837 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
838 }
839
840 #[test]
841 fn test_exec_metacharacters_denied() {
842 let (gate, ctx) = make_enforced_gate(vec!["echo"]);
843 let result = gate.check(CheckRequest::Exec {
844 context: &ctx,
845 binary: "echo",
846 args: &["foo; rm -rf /".to_string()],
847 });
848 assert!(result.is_err());
849 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
850 }
851
852 #[test]
853 fn test_exec_path_traversal_denied() {
854 let (gate, ctx) = make_enforced_gate(vec!["cat"]);
855 let result = gate.check(CheckRequest::Exec {
856 context: &ctx,
857 binary: "cat",
858 args: &["../etc/passwd".to_string()],
859 });
860 assert!(result.is_err());
861 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
862 }
863
864 #[test]
865 fn test_exec_enforced_allowed() {
866 let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
867 let result = gate.check(CheckRequest::Exec {
868 context: &ctx,
869 binary: "echo",
870 args: &["hello".to_string(), "world".to_string()],
871 });
872 assert!(result.is_ok(), "listed binary should be allowed");
873 }
874
875 #[test]
878 fn test_path_read_allowed() {
879 let (gate, ctx) = make_gate();
880 let result = gate.check(CheckRequest::Path {
881 context: &ctx,
882 path: Path::new("/workspace/project/file.rs"),
883 mode: PathMode::Read,
884 });
885 assert!(result.is_ok(), "workspace path should be readable");
886 }
887
888 #[test]
891 fn test_network_denied_by_default() {
892 let (gate, ctx) = make_gate();
893 let result = gate.check(CheckRequest::Network { context: &ctx });
894 assert!(result.is_err());
895 assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
896 }
897
898 #[test]
901 fn test_fork_denied_by_default() {
902 let (gate, ctx) = make_gate();
903 let result = gate.check(CheckRequest::Fork { context: &ctx });
904 assert!(result.is_err());
908 }
909
910 #[test]
913 fn test_deny_layer_display() {
914 assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
915 assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
916 assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
917 assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
918 }
919
920 #[test]
923 fn test_no_metacharacters_in_clean_args() {
924 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
925 }
926
927 #[test]
928 fn test_metacharacters_semicolon() {
929 assert!(has_metacharacters(&["foo;bar".into()]));
930 }
931
932 #[test]
933 fn test_metacharacters_pipe() {
934 assert!(has_metacharacters(&["a | b".into()]));
935 }
936
937 #[test]
938 fn test_metacharacters_dollar() {
939 assert!(has_metacharacters(&["$(whoami)".into()]));
940 }
941
942 #[test]
943 fn test_metacharacters_path_traversal() {
944 assert!(has_metacharacters(&["../etc/passwd".into()]));
945 }
946
947 #[test]
950 fn test_access_denied_display() {
951 let denied = AccessDenied {
952 agent: "test".into(),
953 resource: "exec".into(),
954 layer: DenyLayer::ExecPolicy,
955 reason: "not in allowlist".into(),
956 suggestion: Some("add to config".into()),
957 };
958 let s = format!("{}", denied);
959 assert!(s.contains("[ExecPolicy]"));
960 assert!(s.contains("not in allowlist"));
961 }
962}