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에 '{tool}' 도구에 대한 EXECUTE capability 없음"),
325 suggestion: Some(format!(
326 "에이전트의 Seed에 '{tool}' capability를 추가하세요."
327 )),
328 });
329 }
330 }
331
332 let mut access = self.access.lock();
334 if !access.can_use_tool(&ctx.agent_name, tool) {
335 return Err(AccessDenied {
336 agent: ctx.agent_name.clone(),
337 resource: tool.to_string(),
338 layer: DenyLayer::Permission,
339 reason: format!(
340 "Agent '{}'의 allowed_tools에 '{}' 없음",
341 ctx.agent_name, tool
342 ),
343 suggestion: Some(format!(
344 "관리자에게 '{}' 에이전트의 '{}' 도구 권한을 요청하세요.",
345 ctx.agent_name, tool
346 )),
347 });
348 }
349
350 Ok(())
351 }
352
353 fn check_path(
354 &self,
355 ctx: &AgentContext,
356 path: &Path,
357 mode: PathMode,
358 ) -> Result<(), AccessDenied> {
359 let resolved = if path.is_relative() {
365 std::env::current_dir()
366 .unwrap_or_else(|_| std::path::PathBuf::from("."))
367 .join(path)
368 } else {
369 path.to_path_buf()
370 };
371 let resolved = canonicalize_for_check(&resolved);
372 let path_str = resolved.to_string_lossy();
373
374 let resource = ResourceRef::KernelDomain {
376 domain: "fs".to_string(),
377 };
378 let required = match mode {
379 PathMode::Read => Rights::READ,
380 PathMode::Write => Rights::WRITE,
381 };
382 if !ctx.cspace.can(&resource, required) {
383 tracing::debug!(
386 agent = %ctx.agent_name,
387 mode = %mode,
388 "CSpace does not contain fs capability, proceeding (advisory)"
389 );
390 }
391
392 let mut access = self.access.lock();
394 let rbac_subject = Subject::Agent(ctx.agent_id);
395 let rbac_action = Action::AccessPath(path_str.to_string());
396 if !access
397 .rbac_manager_mut()
398 .check_permission(&rbac_subject, &rbac_action, &path_str)
399 {
400 return Err(AccessDenied {
401 agent: ctx.agent_name.clone(),
402 resource: path_str.to_string(),
403 layer: DenyLayer::Rbac,
404 reason: "RBAC 정책이 경로 접근을 허용하지 않음".into(),
405 suggestion: Some("RBAC 정책을 확인하세요.".into()),
406 });
407 }
408
409 if !access.can_access_path(&ctx.agent_name, &path_str) {
411 return Err(AccessDenied {
412 agent: ctx.agent_name.clone(),
413 resource: path_str.to_string(),
414 layer: DenyLayer::Permission,
415 reason: format!("경로 '{path_str}'이(가) 허용 목록에 없거나 거부 목록에 포함됨"),
416 suggestion: Some("allowed_paths / denied_paths 설정을 확인하세요.".into()),
417 });
418 }
419
420 if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
422 && !access.is_path_in_workspace(&ws, &path_str)
423 {
424 self.audit.record(AuditEvent::SandboxViolation {
426 timestamp: chrono::Utc::now(),
427 agent: ctx.agent_name.clone(),
428 path: path_str.to_string(),
429 workspace: ws.clone(),
430 });
431 return Err(AccessDenied {
432 agent: ctx.agent_name.clone(),
433 resource: path_str.to_string(),
434 layer: DenyLayer::Permission,
435 reason: format!("경로 '{path_str}'이(가) 워크스페이스 '{ws}' 경계를 벗어남"),
436 suggestion: None,
437 });
438 }
439
440 Ok(())
441 }
442
443 fn check_exec(
444 &self,
445 ctx: &AgentContext,
446 binary: &str,
447 args: &[String],
448 ) -> Result<(), AccessDenied> {
449 let resource = ResourceRef::Exec {
451 mode: "structured".to_string(),
452 };
453 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
454 let shell_resource = ResourceRef::Exec {
456 mode: "shell".to_string(),
457 };
458 if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
459 && !ctx.cspace.can(&resource, Rights::EXECUTE)
460 {
461 return Err(AccessDenied {
462 agent: ctx.agent_name.clone(),
463 resource: binary.to_string(),
464 layer: DenyLayer::Capability,
465 reason: "CSpace에 Exec capability 없음".into(),
466 suggestion: Some("Seed에 Exec capability를 추가하세요.".into()),
467 });
468 }
469 }
470
471 let mut access = self.access.lock();
475 if !access.can_use_tool(&ctx.agent_name, "exec") {
476 return Err(AccessDenied {
477 agent: ctx.agent_name.clone(),
478 resource: binary.to_string(),
479 layer: DenyLayer::Permission,
480 reason: format!("에이전트가 '{binary}' 실행 권한 없음"),
481 suggestion: None,
482 });
483 }
484
485 if !self.exec_config.is_binary_allowed(binary) {
487 return Err(AccessDenied {
488 agent: ctx.agent_name.clone(),
489 resource: binary.to_string(),
490 layer: DenyLayer::ExecPolicy,
491 reason: format!("바이너리 '{binary}'이(가) 허용 목록에 없음"),
492 suggestion: Some("exec.allowed_commands에 추가하세요.".into()),
493 });
494 }
495
496 if has_metacharacters(args) {
498 return Err(AccessDenied {
499 agent: ctx.agent_name.clone(),
500 resource: binary.to_string(),
501 layer: DenyLayer::ExecPolicy,
502 reason: "인수에 셸 메타문자 또는 경로 순회 패턴 포함".into(),
503 suggestion: None,
504 });
505 }
506
507 Ok(())
508 }
509
510 fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
511 let mut access = self.access.lock();
512 if !access.can_access_network(&ctx.agent_name) {
513 return Err(AccessDenied {
514 agent: ctx.agent_name.clone(),
515 resource: "<network>".into(),
516 layer: DenyLayer::Permission,
517 reason: "네트워크 접근이 비활성화됨".into(),
518 suggestion: Some("permissions.network_access를 true로 설정하세요.".into()),
519 });
520 }
521 Ok(())
522 }
523
524 fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
525 let resource = ResourceRef::KernelDomain {
527 domain: "agent".to_string(),
528 };
529 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
530 return Err(AccessDenied {
531 agent: ctx.agent_name.clone(),
532 resource: "fork".into(),
533 layer: DenyLayer::Capability,
534 reason: "CSpace에 에이전트 관리 capability 없음".into(),
535 suggestion: None,
536 });
537 }
538
539 let access = self.access.lock();
541 if !access.can_fork(&ctx.agent_name) {
542 return Err(AccessDenied {
543 agent: ctx.agent_name.clone(),
544 resource: "fork".into(),
545 layer: DenyLayer::Permission,
546 reason: "에이전트 fork 권한 없음".into(),
547 suggestion: Some("permissions.can_fork를 true로 설정하세요.".into()),
548 });
549 }
550 Ok(())
551 }
552
553 fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
556 let event = match result {
557 Ok(()) => self.allowed_event(req),
558 Err(denied) => self.denied_event(req, denied),
559 };
560 self.audit.record(event);
561 }
562
563 fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
564 let ctx = req.agent_context();
565 let ts = chrono::Utc::now();
566 match req {
567 CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
568 timestamp: ts,
569 agent: ctx.agent_name.clone(),
570 tool: tool_name.to_string(),
571 allowed: true,
572 layer: None,
573 reason: None,
574 },
575 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
576 timestamp: ts,
577 agent: ctx.agent_name.clone(),
578 path: path.to_string_lossy().to_string(),
579 mode: mode.to_string(),
580 allowed: true,
581 layer: None,
582 reason: None,
583 },
584 CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
585 timestamp: ts,
586 agent: ctx.agent_name.clone(),
587 binary: binary.to_string(),
588 allowed: true,
589 layer: None,
590 reason: None,
591 },
592 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
593 timestamp: ts,
594 agent: ctx.agent_name.clone(),
595 tool: "network".into(),
596 allowed: true,
597 layer: None,
598 reason: None,
599 },
600 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
601 timestamp: ts,
602 agent: ctx.agent_name.clone(),
603 tool: "fork".into(),
604 allowed: true,
605 layer: None,
606 reason: None,
607 },
608 }
609 }
610
611 fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
612 let ctx = req.agent_context();
613 let ts = chrono::Utc::now();
614 let layer = Some(denied.layer.to_string());
615 let reason = Some(denied.reason.clone());
616
617 match req {
618 CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
619 timestamp: ts,
620 agent: ctx.agent_name.clone(),
621 tool: denied.resource.clone(),
622 allowed: false,
623 layer,
624 reason,
625 },
626 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
627 timestamp: ts,
628 agent: ctx.agent_name.clone(),
629 path: path.to_string_lossy().to_string(),
630 mode: mode.to_string(),
631 allowed: false,
632 layer,
633 reason,
634 },
635 CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
636 timestamp: ts,
637 agent: ctx.agent_name.clone(),
638 binary: denied.resource.clone(),
639 allowed: false,
640 layer,
641 reason,
642 },
643 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
644 timestamp: ts,
645 agent: ctx.agent_name.clone(),
646 tool: "network".into(),
647 allowed: false,
648 layer,
649 reason,
650 },
651 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
652 timestamp: ts,
653 agent: ctx.agent_name.clone(),
654 tool: "fork".into(),
655 allowed: false,
656 layer,
657 reason,
658 },
659 }
660 }
661}
662
663impl std::fmt::Debug for AccessGate {
664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665 f.debug_struct("AccessGate").finish()
666 }
667}
668
669#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::access_manager::AgentPermissions;
675 use crate::access_manager::audit_sink::NoOpAuditSink;
676 use crate::config::AllowlistMode;
677
678 fn make_gate() -> (AccessGate, AgentContext) {
680 let mut access = AccessManager::new();
681
682 let ctx = AgentContext::test_fixture("test-agent");
684
685 let mut perms = AgentPermissions::for_new_agent("test-agent");
687 perms.allow_path("/workspace/**");
688 perms.allow_path("/tmp/**");
689 access.set_permissions(perms);
690
691 let subject = Subject::Agent(ctx.agent_id);
693 access
694 .rbac_manager_mut()
695 .assign_role(subject, crate::access_manager::Role::Superuser);
696
697 let gate = AccessGate::new(
698 Arc::new(Mutex::new(access)),
699 Arc::new(ExecConfig {
700 allowlist_mode: AllowlistMode::Permissive, ..Default::default()
702 }),
703 Arc::new(NoOpAuditSink),
704 );
705
706 (gate, ctx)
707 }
708
709 fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
711 let mut access = AccessManager::new();
712 let ctx = AgentContext::test_fixture("test-agent");
713
714 let perms = AgentPermissions::for_new_agent("test-agent");
715 access.set_permissions(perms);
716
717 let subject = Subject::Agent(ctx.agent_id);
718 access
719 .rbac_manager_mut()
720 .assign_role(subject, crate::access_manager::Role::Superuser);
721
722 let config = ExecConfig {
723 allowlist_mode: AllowlistMode::Enforced,
724 allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
725 ..Default::default()
726 };
727
728 let gate = AccessGate::new(
729 Arc::new(Mutex::new(access)),
730 Arc::new(config),
731 Arc::new(NoOpAuditSink),
732 );
733
734 (gate, ctx)
735 }
736
737 #[test]
740 fn test_tool_access_allowed() {
741 let (gate, ctx) = make_gate();
742 let result = gate.check(CheckRequest::Tool {
743 context: &ctx,
744 tool_name: "bash",
745 });
746 assert!(result.is_ok(), "bash should be allowed: {:?}", result);
747 }
748
749 #[test]
750 fn test_tool_access_unknown_agent_denied() {
751 let gate = AccessGate::new(
752 Arc::new(Mutex::new(AccessManager::new())), Arc::new(ExecConfig::default()),
754 Arc::new(NoOpAuditSink),
755 );
756 let ctx = AgentContext::test_fixture("unknown");
757
758 let result = gate.check(CheckRequest::Tool {
759 context: &ctx,
760 tool_name: "exec",
761 });
762 assert!(result.is_err());
763 let err = result.unwrap_err();
764 assert_eq!(err.layer, DenyLayer::Permission);
765 }
766
767 #[test]
770 fn test_exec_allowed_permissive() {
771 let (gate, ctx) = make_gate();
772 let result = gate.check(CheckRequest::Exec {
773 context: &ctx,
774 binary: "echo",
775 args: &["hello".to_string()],
776 });
777 assert!(result.is_ok(), "echo should be allowed in permissive mode");
778 }
779
780 #[test]
781 fn test_exec_denied_enforced() {
782 let (gate, ctx) = make_enforced_gate(vec!["git"]);
783 let result = gate.check(CheckRequest::Exec {
784 context: &ctx,
785 binary: "rm",
786 args: &[],
787 });
788 assert!(result.is_err());
789 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
790 }
791
792 #[test]
793 fn test_exec_metacharacters_denied() {
794 let (gate, ctx) = make_enforced_gate(vec!["echo"]);
795 let result = gate.check(CheckRequest::Exec {
796 context: &ctx,
797 binary: "echo",
798 args: &["foo; rm -rf /".to_string()],
799 });
800 assert!(result.is_err());
801 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
802 }
803
804 #[test]
805 fn test_exec_path_traversal_denied() {
806 let (gate, ctx) = make_enforced_gate(vec!["cat"]);
807 let result = gate.check(CheckRequest::Exec {
808 context: &ctx,
809 binary: "cat",
810 args: &["../etc/passwd".to_string()],
811 });
812 assert!(result.is_err());
813 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
814 }
815
816 #[test]
817 fn test_exec_enforced_allowed() {
818 let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
819 let result = gate.check(CheckRequest::Exec {
820 context: &ctx,
821 binary: "echo",
822 args: &["hello".to_string(), "world".to_string()],
823 });
824 assert!(result.is_ok(), "listed binary should be allowed");
825 }
826
827 #[test]
830 fn test_path_read_allowed() {
831 let (gate, ctx) = make_gate();
832 let result = gate.check(CheckRequest::Path {
833 context: &ctx,
834 path: Path::new("/workspace/project/file.rs"),
835 mode: PathMode::Read,
836 });
837 assert!(result.is_ok(), "workspace path should be readable");
838 }
839
840 #[test]
843 fn test_network_denied_by_default() {
844 let (gate, ctx) = make_gate();
845 let result = gate.check(CheckRequest::Network { context: &ctx });
846 assert!(result.is_err());
847 assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
848 }
849
850 #[test]
853 fn test_fork_denied_by_default() {
854 let (gate, ctx) = make_gate();
855 let result = gate.check(CheckRequest::Fork { context: &ctx });
856 assert!(result.is_err());
860 }
861
862 #[test]
865 fn test_deny_layer_display() {
866 assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
867 assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
868 assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
869 assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
870 }
871
872 #[test]
875 fn test_no_metacharacters_in_clean_args() {
876 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
877 }
878
879 #[test]
880 fn test_metacharacters_semicolon() {
881 assert!(has_metacharacters(&["foo;bar".into()]));
882 }
883
884 #[test]
885 fn test_metacharacters_pipe() {
886 assert!(has_metacharacters(&["a | b".into()]));
887 }
888
889 #[test]
890 fn test_metacharacters_dollar() {
891 assert!(has_metacharacters(&["$(whoami)".into()]));
892 }
893
894 #[test]
895 fn test_metacharacters_path_traversal() {
896 assert!(has_metacharacters(&["../etc/passwd".into()]));
897 }
898
899 #[test]
902 fn test_access_denied_display() {
903 let denied = AccessDenied {
904 agent: "test".into(),
905 resource: "exec".into(),
906 layer: DenyLayer::ExecPolicy,
907 reason: "not in allowlist".into(),
908 suggestion: Some("add to config".into()),
909 };
910 let s = format!("{}", denied);
911 assert!(s.contains("[ExecPolicy]"));
912 assert!(s.contains("not in allowlist"));
913 }
914}