1use std::path::Path;
17use std::sync::Arc;
18
19use parking_lot::Mutex;
20
21use crate::access_manager::audit_sink::{AuditEvent, AuditSink};
22use crate::access_manager::context::AgentContext;
23use crate::access_manager::{AccessManager, Action, Subject};
24use crate::capability::{ResourceRef, Rights};
25use crate::config::ExecConfig;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PathMode {
32 Read,
34 Write,
36}
37
38impl std::fmt::Display for PathMode {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 PathMode::Read => write!(f, "read"),
42 PathMode::Write => write!(f, "write"),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum DenyLayer {
52 Capability,
54 Rbac,
56 Permission,
58 ExecPolicy,
60}
61
62impl std::fmt::Display for DenyLayer {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 DenyLayer::Capability => write!(f, "CSpace"),
66 DenyLayer::Rbac => write!(f, "RBAC"),
67 DenyLayer::Permission => write!(f, "Permissions"),
68 DenyLayer::ExecPolicy => write!(f, "ExecPolicy"),
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
77pub struct AccessDenied {
78 pub agent: String,
80 pub resource: String,
82 pub layer: DenyLayer,
84 pub reason: String,
86 pub suggestion: Option<String>,
88}
89
90impl std::fmt::Display for AccessDenied {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 write!(
93 f,
94 "[{}] {} — {}",
95 self.layer,
96 self.reason,
97 self.suggestion.as_deref().unwrap_or("")
98 )
99 }
100}
101
102#[derive(Debug)]
106pub enum CheckRequest<'a> {
107 Tool {
109 context: &'a AgentContext,
111 tool_name: &'a str,
113 },
114 Path {
116 context: &'a AgentContext,
118 path: &'a Path,
120 mode: PathMode,
122 },
123 Exec {
125 context: &'a AgentContext,
127 binary: &'a str,
129 args: &'a [String],
131 },
132 Network {
134 context: &'a AgentContext,
136 },
137 Fork {
139 context: &'a AgentContext,
141 },
142}
143
144impl<'a> CheckRequest<'a> {
145 pub fn agent_context(&self) -> &AgentContext {
147 match self {
148 CheckRequest::Tool { context, .. } => context,
149 CheckRequest::Path { context, .. } => context,
150 CheckRequest::Exec { context, .. } => context,
151 CheckRequest::Network { context } => context,
152 CheckRequest::Fork { context } => context,
153 }
154 }
155
156 pub fn resource(&self) -> &str {
158 match self {
159 CheckRequest::Tool { tool_name, .. } => tool_name,
160 CheckRequest::Path { path, .. } => path.to_str().unwrap_or("<invalid-path>"),
161 CheckRequest::Exec { binary, .. } => binary,
162 CheckRequest::Network { .. } => "<network>",
163 CheckRequest::Fork { .. } => "fork",
164 }
165 }
166}
167
168const SHELL_METACHARS: &[char] = &[
172 '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
173];
174
175fn has_metacharacters(args: &[String]) -> bool {
177 for arg in args {
178 if arg.contains("..") {
179 return true;
180 }
181 if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
182 return true;
183 }
184 }
185 false
186}
187
188pub struct AccessGate {
212 access: Arc<Mutex<AccessManager>>,
214 exec_config: Arc<ExecConfig>,
216 audit: Arc<dyn AuditSink>,
218}
219
220impl AccessGate {
221 pub fn new(
223 access: Arc<Mutex<AccessManager>>,
224 exec_config: Arc<ExecConfig>,
225 audit: Arc<dyn AuditSink>,
226 ) -> Self {
227 Self {
228 access,
229 exec_config,
230 audit,
231 }
232 }
233
234 pub fn access_clone(&self) -> Arc<Mutex<AccessManager>> {
236 self.access.clone()
237 }
238
239 pub fn check(&self, req: CheckRequest<'_>) -> Result<(), AccessDenied> {
245 let result = match &req {
246 CheckRequest::Tool { context, tool_name } => self.check_tool(context, tool_name),
247 CheckRequest::Path {
248 context,
249 path,
250 mode,
251 } => self.check_path(context, path, *mode),
252 CheckRequest::Exec {
253 context,
254 binary,
255 args,
256 } => self.check_exec(context, binary, args),
257 CheckRequest::Network { context } => self.check_network(context),
258 CheckRequest::Fork { context } => self.check_fork(context),
259 };
260
261 self.record_check(&req, &result);
263
264 result
265 }
266
267 fn check_tool(&self, ctx: &AgentContext, tool: &str) -> Result<(), AccessDenied> {
270 let resource = ResourceRef::KernelDomain {
272 domain: tool.to_string(),
273 };
274 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
275 let always_on = [
278 "read",
279 "write",
280 "edit",
281 "grep",
282 "find",
283 "ls",
284 "web_search",
285 "browse",
286 "browse_extract",
287 "browse_script",
288 "knowledge_save",
289 "knowledge_search",
290 ];
291 if !always_on.contains(&tool) {
292 return Err(AccessDenied {
293 agent: ctx.agent_name.clone(),
294 resource: tool.to_string(),
295 layer: DenyLayer::Capability,
296 reason: format!("CSpace에 '{tool}' 도구에 대한 EXECUTE capability 없음"),
297 suggestion: Some(format!(
298 "에이전트의 Seed에 '{tool}' capability를 추가하세요."
299 )),
300 });
301 }
302 }
303
304 let mut access = self.access.lock();
306 if !access.can_use_tool(&ctx.agent_name, tool) {
307 return Err(AccessDenied {
308 agent: ctx.agent_name.clone(),
309 resource: tool.to_string(),
310 layer: DenyLayer::Permission,
311 reason: format!(
312 "Agent '{}'의 allowed_tools에 '{}' 없음",
313 ctx.agent_name, tool
314 ),
315 suggestion: Some(format!(
316 "관리자에게 '{}' 에이전트의 '{}' 도구 권한을 요청하세요.",
317 ctx.agent_name, tool
318 )),
319 });
320 }
321
322 Ok(())
323 }
324
325 fn check_path(
326 &self,
327 ctx: &AgentContext,
328 path: &Path,
329 mode: PathMode,
330 ) -> Result<(), AccessDenied> {
331 let resolved = if path.is_relative() {
335 std::env::current_dir()
336 .unwrap_or_else(|_| std::path::PathBuf::from("."))
337 .join(path)
338 } else {
339 path.to_path_buf()
340 };
341 let path_str = resolved.to_string_lossy();
342
343 let resource = ResourceRef::KernelDomain {
345 domain: "fs".to_string(),
346 };
347 let required = match mode {
348 PathMode::Read => Rights::READ,
349 PathMode::Write => Rights::WRITE,
350 };
351 if !ctx.cspace.can(&resource, required) {
352 tracing::debug!(
355 agent = %ctx.agent_name,
356 mode = %mode,
357 "CSpace does not contain fs capability, proceeding (advisory)"
358 );
359 }
360
361 let mut access = self.access.lock();
363 let rbac_subject = Subject::Agent(ctx.agent_id);
364 let rbac_action = Action::AccessPath(path_str.to_string());
365 if !access
366 .rbac_manager_mut()
367 .check_permission(&rbac_subject, &rbac_action, &path_str)
368 {
369 return Err(AccessDenied {
370 agent: ctx.agent_name.clone(),
371 resource: path_str.to_string(),
372 layer: DenyLayer::Rbac,
373 reason: "RBAC 정책이 경로 접근을 허용하지 않음".into(),
374 suggestion: Some("RBAC 정책을 확인하세요.".into()),
375 });
376 }
377
378 if !access.can_access_path(&ctx.agent_name, &path_str) {
380 return Err(AccessDenied {
381 agent: ctx.agent_name.clone(),
382 resource: path_str.to_string(),
383 layer: DenyLayer::Permission,
384 reason: format!("경로 '{path_str}'이(가) 허용 목록에 없거나 거부 목록에 포함됨"),
385 suggestion: Some("allowed_paths / denied_paths 설정을 확인하세요.".into()),
386 });
387 }
388
389 if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
391 && !access.is_path_in_workspace(&ws, &path_str)
392 {
393 self.audit.record(AuditEvent::SandboxViolation {
395 timestamp: chrono::Utc::now(),
396 agent: ctx.agent_name.clone(),
397 path: path_str.to_string(),
398 workspace: ws.clone(),
399 });
400 return Err(AccessDenied {
401 agent: ctx.agent_name.clone(),
402 resource: path_str.to_string(),
403 layer: DenyLayer::Permission,
404 reason: format!("경로 '{path_str}'이(가) 워크스페이스 '{ws}' 경계를 벗어남"),
405 suggestion: None,
406 });
407 }
408
409 Ok(())
410 }
411
412 fn check_exec(
413 &self,
414 ctx: &AgentContext,
415 binary: &str,
416 args: &[String],
417 ) -> Result<(), AccessDenied> {
418 let resource = ResourceRef::Exec {
420 mode: "structured".to_string(),
421 };
422 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
423 let shell_resource = ResourceRef::Exec {
425 mode: "shell".to_string(),
426 };
427 if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
428 && !ctx.cspace.can(&resource, Rights::EXECUTE)
429 {
430 return Err(AccessDenied {
431 agent: ctx.agent_name.clone(),
432 resource: binary.to_string(),
433 layer: DenyLayer::Capability,
434 reason: "CSpace에 Exec capability 없음".into(),
435 suggestion: Some("Seed에 Exec capability를 추가하세요.".into()),
436 });
437 }
438 }
439
440 let mut access = self.access.lock();
443 if !access.can_use_tool(&ctx.agent_name, "exec") {
444 let tool_name = if binary == "bash" { "bash" } else { binary };
446 if !access.can_use_tool(&ctx.agent_name, tool_name) {
447 return Err(AccessDenied {
448 agent: ctx.agent_name.clone(),
449 resource: binary.to_string(),
450 layer: DenyLayer::Permission,
451 reason: format!("에이전트가 '{binary}' 실행 권한 없음"),
452 suggestion: None,
453 });
454 }
455 }
456
457 if !self.exec_config.is_binary_allowed(binary) {
459 return Err(AccessDenied {
460 agent: ctx.agent_name.clone(),
461 resource: binary.to_string(),
462 layer: DenyLayer::ExecPolicy,
463 reason: format!("바이너리 '{binary}'이(가) 허용 목록에 없음"),
464 suggestion: Some("exec.allowed_commands에 추가하세요.".into()),
465 });
466 }
467
468 if has_metacharacters(args) {
470 return Err(AccessDenied {
471 agent: ctx.agent_name.clone(),
472 resource: binary.to_string(),
473 layer: DenyLayer::ExecPolicy,
474 reason: "인수에 셸 메타문자 또는 경로 순회 패턴 포함".into(),
475 suggestion: None,
476 });
477 }
478
479 Ok(())
480 }
481
482 fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
483 let mut access = self.access.lock();
484 if !access.can_access_network(&ctx.agent_name) {
485 return Err(AccessDenied {
486 agent: ctx.agent_name.clone(),
487 resource: "<network>".into(),
488 layer: DenyLayer::Permission,
489 reason: "네트워크 접근이 비활성화됨".into(),
490 suggestion: Some("permissions.network_access를 true로 설정하세요.".into()),
491 });
492 }
493 Ok(())
494 }
495
496 fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
497 let resource = ResourceRef::KernelDomain {
499 domain: "agent".to_string(),
500 };
501 if !ctx.cspace.can(&resource, Rights::EXECUTE) {
502 return Err(AccessDenied {
503 agent: ctx.agent_name.clone(),
504 resource: "fork".into(),
505 layer: DenyLayer::Capability,
506 reason: "CSpace에 에이전트 관리 capability 없음".into(),
507 suggestion: None,
508 });
509 }
510
511 let access = self.access.lock();
513 if !access.can_fork(&ctx.agent_name) {
514 return Err(AccessDenied {
515 agent: ctx.agent_name.clone(),
516 resource: "fork".into(),
517 layer: DenyLayer::Permission,
518 reason: "에이전트 fork 권한 없음".into(),
519 suggestion: Some("permissions.can_fork를 true로 설정하세요.".into()),
520 });
521 }
522 Ok(())
523 }
524
525 fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
528 let event = match result {
529 Ok(()) => self.allowed_event(req),
530 Err(denied) => self.denied_event(req, denied),
531 };
532 self.audit.record(event);
533 }
534
535 fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
536 let ctx = req.agent_context();
537 let ts = chrono::Utc::now();
538 match req {
539 CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
540 timestamp: ts,
541 agent: ctx.agent_name.clone(),
542 tool: tool_name.to_string(),
543 allowed: true,
544 layer: None,
545 reason: None,
546 },
547 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
548 timestamp: ts,
549 agent: ctx.agent_name.clone(),
550 path: path.to_string_lossy().to_string(),
551 mode: mode.to_string(),
552 allowed: true,
553 layer: None,
554 reason: None,
555 },
556 CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
557 timestamp: ts,
558 agent: ctx.agent_name.clone(),
559 binary: binary.to_string(),
560 allowed: true,
561 layer: None,
562 reason: None,
563 },
564 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
565 timestamp: ts,
566 agent: ctx.agent_name.clone(),
567 tool: "network".into(),
568 allowed: true,
569 layer: None,
570 reason: None,
571 },
572 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
573 timestamp: ts,
574 agent: ctx.agent_name.clone(),
575 tool: "fork".into(),
576 allowed: true,
577 layer: None,
578 reason: None,
579 },
580 }
581 }
582
583 fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
584 let ctx = req.agent_context();
585 let ts = chrono::Utc::now();
586 let layer = Some(denied.layer.to_string());
587 let reason = Some(denied.reason.clone());
588
589 match req {
590 CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
591 timestamp: ts,
592 agent: ctx.agent_name.clone(),
593 tool: denied.resource.clone(),
594 allowed: false,
595 layer,
596 reason,
597 },
598 CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
599 timestamp: ts,
600 agent: ctx.agent_name.clone(),
601 path: path.to_string_lossy().to_string(),
602 mode: mode.to_string(),
603 allowed: false,
604 layer,
605 reason,
606 },
607 CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
608 timestamp: ts,
609 agent: ctx.agent_name.clone(),
610 binary: denied.resource.clone(),
611 allowed: false,
612 layer,
613 reason,
614 },
615 CheckRequest::Network { .. } => AuditEvent::ToolAccess {
616 timestamp: ts,
617 agent: ctx.agent_name.clone(),
618 tool: "network".into(),
619 allowed: false,
620 layer,
621 reason,
622 },
623 CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
624 timestamp: ts,
625 agent: ctx.agent_name.clone(),
626 tool: "fork".into(),
627 allowed: false,
628 layer,
629 reason,
630 },
631 }
632 }
633}
634
635impl std::fmt::Debug for AccessGate {
636 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
637 f.debug_struct("AccessGate").finish()
638 }
639}
640
641#[cfg(test)]
644mod tests {
645 use super::*;
646 use crate::access_manager::AgentPermissions;
647 use crate::access_manager::audit_sink::NoOpAuditSink;
648 use crate::config::AllowlistMode;
649
650 fn make_gate() -> (AccessGate, AgentContext) {
652 let mut access = AccessManager::new();
653
654 let ctx = AgentContext::test_fixture("test-agent");
656
657 let mut perms = AgentPermissions::for_new_agent("test-agent");
659 perms.allow_path("/workspace/**");
660 perms.allow_path("/tmp/**");
661 access.set_permissions(perms);
662
663 let subject = Subject::Agent(ctx.agent_id);
665 access
666 .rbac_manager_mut()
667 .assign_role(subject, crate::access_manager::Role::Superuser);
668
669 let gate = AccessGate::new(
670 Arc::new(Mutex::new(access)),
671 Arc::new(ExecConfig {
672 allowlist_mode: AllowlistMode::Permissive, ..Default::default()
674 }),
675 Arc::new(NoOpAuditSink),
676 );
677
678 (gate, ctx)
679 }
680
681 fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
683 let mut access = AccessManager::new();
684 let ctx = AgentContext::test_fixture("test-agent");
685
686 let perms = AgentPermissions::for_new_agent("test-agent");
687 access.set_permissions(perms);
688
689 let subject = Subject::Agent(ctx.agent_id);
690 access
691 .rbac_manager_mut()
692 .assign_role(subject, crate::access_manager::Role::Superuser);
693
694 let config = ExecConfig {
695 allowlist_mode: AllowlistMode::Enforced,
696 allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
697 ..Default::default()
698 };
699
700 let gate = AccessGate::new(
701 Arc::new(Mutex::new(access)),
702 Arc::new(config),
703 Arc::new(NoOpAuditSink),
704 );
705
706 (gate, ctx)
707 }
708
709 #[test]
712 fn test_tool_access_allowed() {
713 let (gate, ctx) = make_gate();
714 let result = gate.check(CheckRequest::Tool {
715 context: &ctx,
716 tool_name: "bash",
717 });
718 assert!(result.is_ok(), "bash should be allowed: {:?}", result);
719 }
720
721 #[test]
722 fn test_tool_access_unknown_agent_denied() {
723 let gate = AccessGate::new(
724 Arc::new(Mutex::new(AccessManager::new())), Arc::new(ExecConfig::default()),
726 Arc::new(NoOpAuditSink),
727 );
728 let ctx = AgentContext::test_fixture("unknown");
729
730 let result = gate.check(CheckRequest::Tool {
731 context: &ctx,
732 tool_name: "exec",
733 });
734 assert!(result.is_err());
735 let err = result.unwrap_err();
736 assert_eq!(err.layer, DenyLayer::Permission);
737 }
738
739 #[test]
742 fn test_exec_allowed_permissive() {
743 let (gate, ctx) = make_gate();
744 let result = gate.check(CheckRequest::Exec {
745 context: &ctx,
746 binary: "echo",
747 args: &["hello".to_string()],
748 });
749 assert!(result.is_ok(), "echo should be allowed in permissive mode");
750 }
751
752 #[test]
753 fn test_exec_denied_enforced() {
754 let (gate, ctx) = make_enforced_gate(vec!["git"]);
755 let result = gate.check(CheckRequest::Exec {
756 context: &ctx,
757 binary: "rm",
758 args: &[],
759 });
760 assert!(result.is_err());
761 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
762 }
763
764 #[test]
765 fn test_exec_metacharacters_denied() {
766 let (gate, ctx) = make_enforced_gate(vec!["echo"]);
767 let result = gate.check(CheckRequest::Exec {
768 context: &ctx,
769 binary: "echo",
770 args: &["foo; rm -rf /".to_string()],
771 });
772 assert!(result.is_err());
773 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
774 }
775
776 #[test]
777 fn test_exec_path_traversal_denied() {
778 let (gate, ctx) = make_enforced_gate(vec!["cat"]);
779 let result = gate.check(CheckRequest::Exec {
780 context: &ctx,
781 binary: "cat",
782 args: &["../etc/passwd".to_string()],
783 });
784 assert!(result.is_err());
785 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
786 }
787
788 #[test]
789 fn test_exec_enforced_allowed() {
790 let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
791 let result = gate.check(CheckRequest::Exec {
792 context: &ctx,
793 binary: "echo",
794 args: &["hello".to_string(), "world".to_string()],
795 });
796 assert!(result.is_ok(), "listed binary should be allowed");
797 }
798
799 #[test]
802 fn test_path_read_allowed() {
803 let (gate, ctx) = make_gate();
804 let result = gate.check(CheckRequest::Path {
805 context: &ctx,
806 path: Path::new("/workspace/project/file.rs"),
807 mode: PathMode::Read,
808 });
809 assert!(result.is_ok(), "workspace path should be readable");
810 }
811
812 #[test]
815 fn test_network_denied_by_default() {
816 let (gate, ctx) = make_gate();
817 let result = gate.check(CheckRequest::Network { context: &ctx });
818 assert!(result.is_err());
819 assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
820 }
821
822 #[test]
825 fn test_fork_denied_by_default() {
826 let (gate, ctx) = make_gate();
827 let result = gate.check(CheckRequest::Fork { context: &ctx });
828 assert!(result.is_err());
832 }
833
834 #[test]
837 fn test_deny_layer_display() {
838 assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
839 assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
840 assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
841 assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
842 }
843
844 #[test]
847 fn test_no_metacharacters_in_clean_args() {
848 assert!(!has_metacharacters(&["hello".into(), "world".into()]));
849 }
850
851 #[test]
852 fn test_metacharacters_semicolon() {
853 assert!(has_metacharacters(&["foo;bar".into()]));
854 }
855
856 #[test]
857 fn test_metacharacters_pipe() {
858 assert!(has_metacharacters(&["a | b".into()]));
859 }
860
861 #[test]
862 fn test_metacharacters_dollar() {
863 assert!(has_metacharacters(&["$(whoami)".into()]));
864 }
865
866 #[test]
867 fn test_metacharacters_path_traversal() {
868 assert!(has_metacharacters(&["../etc/passwd".into()]));
869 }
870
871 #[test]
874 fn test_access_denied_display() {
875 let denied = AccessDenied {
876 agent: "test".into(),
877 resource: "exec".into(),
878 layer: DenyLayer::ExecPolicy,
879 reason: "not in allowlist".into(),
880 suggestion: Some("add to config".into()),
881 };
882 let s = format!("{}", denied);
883 assert!(s.contains("[ExecPolicy]"));
884 assert!(s.contains("not in allowlist"));
885 }
886}