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 {
311 domain: tool.to_string(),
312 };
313 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
314 let always_on = ["read", "write", "edit", "grep", "find", "ls"];
319 if !always_on.contains(&tool) {
320 return Err(AccessDenied {
321 agent: ctx.agent_name.clone(),
322 resource: tool.to_string(),
323 layer: DenyLayer::Capability,
324 reason: format!("CSpace lacks EXECUTE capability for tool '{tool}'"),
325 suggestion: Some(format!("Add the '{tool}' capability to the agent's Seed.")),
326 });
327 }
328 }
329
330 let mut access = self.access.lock();
332 if !access.can_use_tool(&ctx.agent_name, tool) {
333 return Err(AccessDenied {
334 agent: ctx.agent_name.clone(),
335 resource: tool.to_string(),
336 layer: DenyLayer::Permission,
337 reason: format!(
338 "'{}' is not in agent '{}'s allowed_tools",
339 tool, ctx.agent_name
340 ),
341 suggestion: Some(format!(
342 "Request permission for the '{}' tool on agent '{}' from your administrator.",
343 tool, ctx.agent_name
344 )),
345 });
346 }
347
348 Ok(())
349 }
350
351 fn check_path(
352 &self,
353 ctx: &AgentContext,
354 path: &Path,
355 mode: PathMode,
356 ) -> Result<(), AccessDenied> {
357 let resolved = if path.is_relative() {
363 std::env::current_dir()
364 .unwrap_or_else(|_| std::path::PathBuf::from("."))
365 .join(path)
366 } else {
367 path.to_path_buf()
368 };
369 let resolved = canonicalize_for_check(&resolved);
370 let path_str = resolved.to_string_lossy();
371
372 let resource = ResourceRef::KernelDomain {
374 domain: "fs".to_string(),
375 };
376 let required = match mode {
377 PathMode::Read => Rights::READ,
378 PathMode::Write => Rights::WRITE,
379 };
380 if !ctx.cspace.can(&resource, required) {
381 tracing::debug!(
384 agent = %ctx.agent_name,
385 mode = %mode,
386 "CSpace does not contain fs capability, proceeding (advisory)"
387 );
388 }
389
390 let mut access = self.access.lock();
392 let rbac_subject = Subject::Agent(ctx.agent_id);
393 let rbac_action = Action::AccessPath(path_str.to_string());
394 if !access
395 .rbac_manager_mut()
396 .check_permission(&rbac_subject, &rbac_action, &path_str)
397 {
398 return Err(AccessDenied {
399 agent: ctx.agent_name.clone(),
400 resource: path_str.to_string(),
401 layer: DenyLayer::Rbac,
402 reason: "RBAC policy denies access to this path".into(),
403 suggestion: Some("Review the RBAC policy.".into()),
404 });
405 }
406
407 if !access.can_access_path(&ctx.agent_name, &path_str) {
409 return Err(AccessDenied {
410 agent: ctx.agent_name.clone(),
411 resource: path_str.to_string(),
412 layer: DenyLayer::Permission,
413 reason: format!("Path '{path_str}' is not in allowed_paths or is in denied_paths"),
414 suggestion: Some("Review the allowed_paths / denied_paths settings.".into()),
415 });
416 }
417
418 if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
420 && !access.is_path_in_workspace(&ws, &path_str)
421 {
422 self.audit.record(AuditEvent::SandboxViolation {
424 timestamp: chrono::Utc::now(),
425 agent: ctx.agent_name.clone(),
426 path: path_str.to_string(),
427 workspace: ws.clone(),
428 });
429 return Err(AccessDenied {
430 agent: ctx.agent_name.clone(),
431 resource: path_str.to_string(),
432 layer: DenyLayer::Permission,
433 reason: format!("Path '{path_str}' is outside the '{ws}' workspace boundary"),
434 suggestion: None,
435 });
436 }
437
438 Ok(())
439 }
440
441 fn check_exec(
442 &self,
443 ctx: &AgentContext,
444 binary: &str,
445 args: &[String],
446 ) -> Result<(), AccessDenied> {
447 let resource = ResourceRef::Exec {
449 mode: "structured".to_string(),
450 };
451 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
452 let shell_resource = ResourceRef::Exec {
454 mode: "shell".to_string(),
455 };
456 if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
457 && !ctx.cspace.can(&resource, Rights::EXECUTE)
458 {
459 return Err(AccessDenied {
460 agent: ctx.agent_name.clone(),
461 resource: binary.to_string(),
462 layer: DenyLayer::Capability,
463 reason: "CSpace lacks Exec capability".into(),
464 suggestion: Some("Add the Exec capability to the Seed.".into()),
465 });
466 }
467 }
468
469 let mut access = self.access.lock();
473 if !access.can_use_tool(&ctx.agent_name, "exec") {
474 return Err(AccessDenied {
475 agent: ctx.agent_name.clone(),
476 resource: binary.to_string(),
477 layer: DenyLayer::Permission,
478 reason: format!("Agent lacks permission to execute '{binary}'"),
479 suggestion: None,
480 });
481 }
482
483 if !self.exec_config.is_binary_allowed(binary) {
485 return Err(AccessDenied {
486 agent: ctx.agent_name.clone(),
487 resource: binary.to_string(),
488 layer: DenyLayer::ExecPolicy,
489 reason: format!("Binary '{binary}' is not in the allowlist"),
490 suggestion: Some("Add it to exec.allowed_commands.".into()),
491 });
492 }
493
494 if has_metacharacters(args) {
496 return Err(AccessDenied {
497 agent: ctx.agent_name.clone(),
498 resource: binary.to_string(),
499 layer: DenyLayer::ExecPolicy,
500 reason: "Arguments contain shell metacharacters or path traversal patterns".into(),
501 suggestion: None,
502 });
503 }
504
505 Ok(())
506 }
507
508 fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
509 let mut access = self.access.lock();
510 if !access.can_access_network(&ctx.agent_name) {
511 return Err(AccessDenied {
512 agent: ctx.agent_name.clone(),
513 resource: "<network>".into(),
514 layer: DenyLayer::Permission,
515 reason: "Network access is disabled".into(),
516 suggestion: Some("Set permissions.network_access to true.".into()),
517 });
518 }
519 Ok(())
520 }
521
522 fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
523 let resource = ResourceRef::KernelDomain {
525 domain: "agent".to_string(),
526 };
527 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
528 return Err(AccessDenied {
529 agent: ctx.agent_name.clone(),
530 resource: "fork".into(),
531 layer: DenyLayer::Capability,
532 reason: "CSpace lacks agent-management capability".into(),
533 suggestion: None,
534 });
535 }
536
537 let access = self.access.lock();
539 if !access.can_fork(&ctx.agent_name) {
540 return Err(AccessDenied {
541 agent: ctx.agent_name.clone(),
542 resource: "fork".into(),
543 layer: DenyLayer::Permission,
544 reason: "Agent lacks fork permission".into(),
545 suggestion: Some("Set permissions.can_fork to true.".into()),
546 });
547 }
548 Ok(())
549 }
550
551 fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
554 let event = match result {
555 Ok(()) => self.allowed_event(req),
556 Err(denied) => self.denied_event(req, denied),
557 };
558 self.audit.record(event);
559 }
560
561 fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
562 let ctx = req.agent_context();
563 let ts = chrono::Utc::now();
564 match req {
565 CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
566 timestamp: ts,
567 agent: ctx.agent_name.clone(),
568 tool: tool_name.to_string(),
569 allowed: true,
570 layer: None,
571 reason: None,
572 },
573 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
574 timestamp: ts,
575 agent: ctx.agent_name.clone(),
576 path: path.to_string_lossy().to_string(),
577 mode: mode.to_string(),
578 allowed: true,
579 layer: None,
580 reason: None,
581 },
582 CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
583 timestamp: ts,
584 agent: ctx.agent_name.clone(),
585 binary: binary.to_string(),
586 allowed: true,
587 layer: None,
588 reason: None,
589 },
590 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
591 timestamp: ts,
592 agent: ctx.agent_name.clone(),
593 tool: "network".into(),
594 allowed: true,
595 layer: None,
596 reason: None,
597 },
598 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
599 timestamp: ts,
600 agent: ctx.agent_name.clone(),
601 tool: "fork".into(),
602 allowed: true,
603 layer: None,
604 reason: None,
605 },
606 }
607 }
608
609 fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
610 let ctx = req.agent_context();
611 let ts = chrono::Utc::now();
612 let layer = Some(denied.layer.to_string());
613 let reason = Some(denied.reason.clone());
614
615 match req {
616 CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
617 timestamp: ts,
618 agent: ctx.agent_name.clone(),
619 tool: denied.resource.clone(),
620 allowed: false,
621 layer,
622 reason,
623 },
624 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
625 timestamp: ts,
626 agent: ctx.agent_name.clone(),
627 path: path.to_string_lossy().to_string(),
628 mode: mode.to_string(),
629 allowed: false,
630 layer,
631 reason,
632 },
633 CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
634 timestamp: ts,
635 agent: ctx.agent_name.clone(),
636 binary: denied.resource.clone(),
637 allowed: false,
638 layer,
639 reason,
640 },
641 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
642 timestamp: ts,
643 agent: ctx.agent_name.clone(),
644 tool: "network".into(),
645 allowed: false,
646 layer,
647 reason,
648 },
649 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
650 timestamp: ts,
651 agent: ctx.agent_name.clone(),
652 tool: "fork".into(),
653 allowed: false,
654 layer,
655 reason,
656 },
657 }
658 }
659}
660
661impl std::fmt::Debug for AccessGate {
662 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663 f.debug_struct("AccessGate").finish()
664 }
665}
666
667#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::access_manager::AgentPermissions;
673 use crate::access_manager::audit_sink::NoOpAuditSink;
674 use crate::config::AllowlistMode;
675
676 fn make_gate() -> (AccessGate, AgentContext) {
678 let mut access = AccessManager::new();
679
680 let ctx = AgentContext::test_fixture("test-agent");
682
683 let mut perms = AgentPermissions::for_new_agent("test-agent");
685 perms.allow_path("/workspace/**");
686 perms.allow_path("/tmp/**");
687 access.set_permissions(perms);
688
689 let subject = Subject::Agent(ctx.agent_id);
691 access
692 .rbac_manager_mut()
693 .assign_role(subject, crate::access_manager::Role::Superuser);
694
695 let gate = AccessGate::new(
696 Arc::new(Mutex::new(access)),
697 Arc::new(ExecConfig {
698 allowlist_mode: AllowlistMode::Permissive, ..Default::default()
700 }),
701 Arc::new(NoOpAuditSink),
702 );
703
704 (gate, ctx)
705 }
706
707 fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
709 let mut access = AccessManager::new();
710 let ctx = AgentContext::test_fixture("test-agent");
711
712 let perms = AgentPermissions::for_new_agent("test-agent");
713 access.set_permissions(perms);
714
715 let subject = Subject::Agent(ctx.agent_id);
716 access
717 .rbac_manager_mut()
718 .assign_role(subject, crate::access_manager::Role::Superuser);
719
720 let config = ExecConfig {
721 allowlist_mode: AllowlistMode::Enforced,
722 allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
723 ..Default::default()
724 };
725
726 let gate = AccessGate::new(
727 Arc::new(Mutex::new(access)),
728 Arc::new(config),
729 Arc::new(NoOpAuditSink),
730 );
731
732 (gate, ctx)
733 }
734
735 #[test]
738 fn test_tool_access_allowed() {
739 let (gate, ctx) = make_gate();
740 let result = gate.check(CheckRequest::Tool {
741 context: &ctx,
742 tool_name: "bash",
743 });
744 assert!(result.is_ok(), "bash should be allowed: {:?}", result);
745 }
746
747 #[test]
748 fn test_tool_access_unknown_agent_denied() {
749 let gate = AccessGate::new(
750 Arc::new(Mutex::new(AccessManager::new())), Arc::new(ExecConfig::default()),
752 Arc::new(NoOpAuditSink),
753 );
754 let ctx = AgentContext::test_fixture("unknown");
755
756 let result = gate.check(CheckRequest::Tool {
757 context: &ctx,
758 tool_name: "exec",
759 });
760 assert!(result.is_err());
761 let err = result.unwrap_err();
762 assert_eq!(err.layer, DenyLayer::Permission);
763 }
764
765 #[test]
768 fn test_exec_allowed_permissive() {
769 let (gate, ctx) = make_gate();
770 let result = gate.check(CheckRequest::Exec {
771 context: &ctx,
772 binary: "echo",
773 args: &["hello".to_string()],
774 });
775 assert!(result.is_ok(), "echo should be allowed in permissive mode");
776 }
777
778 #[test]
779 fn test_exec_denied_enforced() {
780 let (gate, ctx) = make_enforced_gate(vec!["git"]);
781 let result = gate.check(CheckRequest::Exec {
782 context: &ctx,
783 binary: "rm",
784 args: &[],
785 });
786 assert!(result.is_err());
787 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
788 }
789
790 #[test]
791 fn test_exec_metacharacters_denied() {
792 let (gate, ctx) = make_enforced_gate(vec!["echo"]);
793 let result = gate.check(CheckRequest::Exec {
794 context: &ctx,
795 binary: "echo",
796 args: &["foo; rm -rf /".to_string()],
797 });
798 assert!(result.is_err());
799 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
800 }
801
802 #[test]
803 fn test_exec_path_traversal_denied() {
804 let (gate, ctx) = make_enforced_gate(vec!["cat"]);
805 let result = gate.check(CheckRequest::Exec {
806 context: &ctx,
807 binary: "cat",
808 args: &["../etc/passwd".to_string()],
809 });
810 assert!(result.is_err());
811 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
812 }
813
814 #[test]
815 fn test_exec_enforced_allowed() {
816 let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
817 let result = gate.check(CheckRequest::Exec {
818 context: &ctx,
819 binary: "echo",
820 args: &["hello".to_string(), "world".to_string()],
821 });
822 assert!(result.is_ok(), "listed binary should be allowed");
823 }
824
825 #[test]
828 fn test_path_read_allowed() {
829 let (gate, ctx) = make_gate();
830 let result = gate.check(CheckRequest::Path {
831 context: &ctx,
832 path: Path::new("/workspace/project/file.rs"),
833 mode: PathMode::Read,
834 });
835 assert!(result.is_ok(), "workspace path should be readable");
836 }
837
838 #[test]
841 fn test_network_denied_by_default() {
842 let (gate, ctx) = make_gate();
843 let result = gate.check(CheckRequest::Network { context: &ctx });
844 assert!(result.is_err());
845 assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
846 }
847
848 #[test]
851 fn test_fork_denied_by_default() {
852 let (gate, ctx) = make_gate();
853 let result = gate.check(CheckRequest::Fork { context: &ctx });
854 assert!(result.is_err());
858 }
859
860 #[test]
863 fn test_deny_layer_display() {
864 assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
865 assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
866 assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
867 assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
868 }
869
870 #[test]
873 fn test_no_metacharacters_in_clean_args() {
874 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
875 }
876
877 #[test]
878 fn test_metacharacters_semicolon() {
879 assert!(has_metacharacters(&["foo;bar".into()]));
880 }
881
882 #[test]
883 fn test_metacharacters_pipe() {
884 assert!(has_metacharacters(&["a | b".into()]));
885 }
886
887 #[test]
888 fn test_metacharacters_dollar() {
889 assert!(has_metacharacters(&["$(whoami)".into()]));
890 }
891
892 #[test]
893 fn test_metacharacters_path_traversal() {
894 assert!(has_metacharacters(&["../etc/passwd".into()]));
895 }
896
897 #[test]
900 fn test_access_denied_display() {
901 let denied = AccessDenied {
902 agent: "test".into(),
903 resource: "exec".into(),
904 layer: DenyLayer::ExecPolicy,
905 reason: "not in allowlist".into(),
906 suggestion: Some("add to config".into()),
907 };
908 let s = format!("{}", denied);
909 assert!(s.contains("[ExecPolicy]"));
910 assert!(s.contains("not in allowlist"));
911 }
912}