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