use std::path::Path;
use std::sync::Arc;
use parking_lot::Mutex;
use crate::access_manager::audit_sink::{AuditEvent, AuditSink};
use crate::access_manager::context::AgentContext;
use crate::access_manager::{AccessManager, Action, Subject};
use crate::capability::{ResourceRef, Rights};
use crate::config::ExecConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathMode {
Read,
Write,
}
impl std::fmt::Display for PathMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathMode::Read => write!(f, "read"),
PathMode::Write => write!(f, "write"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyLayer {
Capability,
Rbac,
Permission,
ExecPolicy,
}
impl std::fmt::Display for DenyLayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DenyLayer::Capability => write!(f, "CSpace"),
DenyLayer::Rbac => write!(f, "RBAC"),
DenyLayer::Permission => write!(f, "Permissions"),
DenyLayer::ExecPolicy => write!(f, "ExecPolicy"),
}
}
}
#[derive(Debug, Clone)]
pub struct AccessDenied {
pub agent: String,
pub resource: String,
pub layer: DenyLayer,
pub reason: String,
pub suggestion: Option<String>,
}
impl std::fmt::Display for AccessDenied {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {} — {}",
self.layer,
self.reason,
self.suggestion.as_deref().unwrap_or("")
)
}
}
#[derive(Debug)]
pub enum CheckRequest<'a> {
Tool {
context: &'a AgentContext,
tool_name: &'a str,
},
Path {
context: &'a AgentContext,
path: &'a Path,
mode: PathMode,
},
Exec {
context: &'a AgentContext,
binary: &'a str,
args: &'a [String],
},
Network {
context: &'a AgentContext,
},
Fork {
context: &'a AgentContext,
},
}
impl<'a> CheckRequest<'a> {
pub fn agent_context(&self) -> &AgentContext {
match self {
CheckRequest::Tool { context, .. } => context,
CheckRequest::Path { context, .. } => context,
CheckRequest::Exec { context, .. } => context,
CheckRequest::Network { context } => context,
CheckRequest::Fork { context } => context,
}
}
pub fn resource(&self) -> &str {
match self {
CheckRequest::Tool { tool_name, .. } => tool_name,
CheckRequest::Path { path, .. } => path.to_str().unwrap_or("<invalid-path>"),
CheckRequest::Exec { binary, .. } => binary,
CheckRequest::Network { .. } => "<network>",
CheckRequest::Fork { .. } => "fork",
}
}
}
const SHELL_METACHARS: &[char] = &[
'|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
];
fn has_metacharacters(args: &[String]) -> bool {
for arg in args {
if arg.contains("..") {
return true;
}
if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
return true;
}
}
false
}
pub struct AccessGate {
access: Arc<Mutex<AccessManager>>,
exec_config: Arc<ExecConfig>,
audit: Arc<dyn AuditSink>,
}
impl AccessGate {
pub fn new(
access: Arc<Mutex<AccessManager>>,
exec_config: Arc<ExecConfig>,
audit: Arc<dyn AuditSink>,
) -> Self {
Self {
access,
exec_config,
audit,
}
}
pub fn access_clone(&self) -> Arc<Mutex<AccessManager>> {
self.access.clone()
}
pub fn check(&self, req: CheckRequest<'_>) -> Result<(), AccessDenied> {
let result = match &req {
CheckRequest::Tool { context, tool_name } => self.check_tool(context, tool_name),
CheckRequest::Path {
context,
path,
mode,
} => self.check_path(context, path, *mode),
CheckRequest::Exec {
context,
binary,
args,
} => self.check_exec(context, binary, args),
CheckRequest::Network { context } => self.check_network(context),
CheckRequest::Fork { context } => self.check_fork(context),
};
self.record_check(&req, &result);
result
}
fn check_tool(&self, ctx: &AgentContext, tool: &str) -> Result<(), AccessDenied> {
let resource = ResourceRef::KernelDomain {
domain: tool.to_string(),
};
if !ctx.cspace.can(&resource, Rights::EXECUTE) {
let always_on = ["read", "write", "edit", "grep", "find", "ls"];
if !always_on.contains(&tool) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: tool.to_string(),
layer: DenyLayer::Capability,
reason: format!("CSpace에 '{tool}' 도구에 대한 EXECUTE capability 없음"),
suggestion: Some(format!(
"에이전트의 Seed에 '{tool}' capability를 추가하세요."
)),
});
}
}
let mut access = self.access.lock();
if !access.can_use_tool(&ctx.agent_name, tool) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: tool.to_string(),
layer: DenyLayer::Permission,
reason: format!(
"Agent '{}'의 allowed_tools에 '{}' 없음",
ctx.agent_name, tool
),
suggestion: Some(format!(
"관리자에게 '{}' 에이전트의 '{}' 도구 권한을 요청하세요.",
ctx.agent_name, tool
)),
});
}
Ok(())
}
fn check_path(
&self,
ctx: &AgentContext,
path: &Path,
mode: PathMode,
) -> Result<(), AccessDenied> {
let path_str = path.to_string_lossy();
let resource = ResourceRef::KernelDomain {
domain: "fs".to_string(),
};
let required = match mode {
PathMode::Read => Rights::READ,
PathMode::Write => Rights::WRITE,
};
if !ctx.cspace.can(&resource, required) {
tracing::debug!(
agent = %ctx.agent_name,
mode = %mode,
"CSpace does not contain fs capability, proceeding (advisory)"
);
}
let mut access = self.access.lock();
let rbac_subject = Subject::Agent(ctx.agent_id);
let rbac_action = Action::AccessPath("/workspace/**".to_string());
if !access
.rbac_manager_mut()
.check_permission(&rbac_subject, &rbac_action, &path_str)
{
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: path_str.to_string(),
layer: DenyLayer::Rbac,
reason: "RBAC 정책이 경로 접근을 허용하지 않음".into(),
suggestion: Some("RBAC 정책을 확인하세요.".into()),
});
}
if !access.can_access_path(&ctx.agent_name, &path_str) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: path_str.to_string(),
layer: DenyLayer::Permission,
reason: format!("경로 '{path_str}'이(가) 허용 목록에 없거나 거부 목록에 포함됨"),
suggestion: Some("allowed_paths / denied_paths 설정을 확인하세요.".into()),
});
}
if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name) {
if !access.is_path_in_workspace(&ws, &path_str) {
self.audit.record(AuditEvent::SandboxViolation {
timestamp: chrono::Utc::now(),
agent: ctx.agent_name.clone(),
path: path_str.to_string(),
workspace: ws.clone(),
});
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: path_str.to_string(),
layer: DenyLayer::Permission,
reason: format!("경로 '{path_str}'이(가) 워크스페이스 '{ws}' 경계를 벗어남"),
suggestion: None,
});
}
}
Ok(())
}
fn check_exec(
&self,
ctx: &AgentContext,
binary: &str,
args: &[String],
) -> Result<(), AccessDenied> {
let resource = ResourceRef::Exec {
mode: "structured".to_string(),
};
if !ctx.cspace.can(&resource, Rights::EXECUTE) {
let shell_resource = ResourceRef::Exec {
mode: "shell".to_string(),
};
if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
&& !ctx.cspace.can(&resource, Rights::EXECUTE)
{
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: binary.to_string(),
layer: DenyLayer::Capability,
reason: "CSpace에 Exec capability 없음".into(),
suggestion: Some("Seed에 Exec capability를 추가하세요.".into()),
});
}
}
let mut access = self.access.lock();
if !access.can_use_tool(&ctx.agent_name, "exec") {
let tool_name = if binary == "bash" { "bash" } else { binary };
if !access.can_use_tool(&ctx.agent_name, tool_name) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: binary.to_string(),
layer: DenyLayer::Permission,
reason: format!("에이전트가 '{binary}' 실행 권한 없음"),
suggestion: None,
});
}
}
if !self.exec_config.is_binary_allowed(binary) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: binary.to_string(),
layer: DenyLayer::ExecPolicy,
reason: format!("바이너리 '{binary}'이(가) 허용 목록에 없음"),
suggestion: Some("exec.allowed_commands에 추가하세요.".into()),
});
}
if has_metacharacters(args) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: binary.to_string(),
layer: DenyLayer::ExecPolicy,
reason: "인수에 셸 메타문자 또는 경로 순회 패턴 포함".into(),
suggestion: None,
});
}
Ok(())
}
fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
let mut access = self.access.lock();
if !access.can_access_network(&ctx.agent_name) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: "<network>".into(),
layer: DenyLayer::Permission,
reason: "네트워크 접근이 비활성화됨".into(),
suggestion: Some("permissions.network_access를 true로 설정하세요.".into()),
});
}
Ok(())
}
fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
let resource = ResourceRef::KernelDomain {
domain: "agent".to_string(),
};
if !ctx.cspace.can(&resource, Rights::EXECUTE) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: "fork".into(),
layer: DenyLayer::Capability,
reason: "CSpace에 에이전트 관리 capability 없음".into(),
suggestion: None,
});
}
let access = self.access.lock();
if !access.can_fork(&ctx.agent_name) {
return Err(AccessDenied {
agent: ctx.agent_name.clone(),
resource: "fork".into(),
layer: DenyLayer::Permission,
reason: "에이전트 fork 권한 없음".into(),
suggestion: Some("permissions.can_fork를 true로 설정하세요.".into()),
});
}
Ok(())
}
fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
let event = match result {
Ok(()) => self.allowed_event(req),
Err(denied) => self.denied_event(req, denied),
};
self.audit.record(event);
}
fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
let ctx = req.agent_context();
let ts = chrono::Utc::now();
match req {
CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
tool: tool_name.to_string(),
allowed: true,
layer: None,
reason: None,
},
CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
path: path.to_string_lossy().to_string(),
mode: mode.to_string(),
allowed: true,
layer: None,
reason: None,
},
CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
binary: binary.to_string(),
allowed: true,
layer: None,
reason: None,
},
CheckRequest::Network { .. } => AuditEvent::ToolAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
tool: "network".into(),
allowed: true,
layer: None,
reason: None,
},
CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
tool: "fork".into(),
allowed: true,
layer: None,
reason: None,
},
}
}
fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
let ctx = req.agent_context();
let ts = chrono::Utc::now();
let layer = Some(denied.layer.to_string());
let reason = Some(denied.reason.clone());
match req {
CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
tool: denied.resource.clone(),
allowed: false,
layer,
reason,
},
CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
path: path.to_string_lossy().to_string(),
mode: mode.to_string(),
allowed: false,
layer,
reason,
},
CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
binary: denied.resource.clone(),
allowed: false,
layer,
reason,
},
CheckRequest::Network { .. } => AuditEvent::ToolAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
tool: "network".into(),
allowed: false,
layer,
reason,
},
CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
timestamp: ts,
agent: ctx.agent_name.clone(),
tool: "fork".into(),
allowed: false,
layer,
reason,
},
}
}
}
impl std::fmt::Debug for AccessGate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccessGate").finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::access_manager::audit_sink::NoOpAuditSink;
use crate::access_manager::AgentPermissions;
use crate::config::AllowlistMode;
fn make_gate() -> (AccessGate, AgentContext) {
let mut access = AccessManager::new();
let ctx = AgentContext::test_fixture("test-agent");
let perms = AgentPermissions::for_new_agent("test-agent");
access.set_permissions(perms);
let subject = Subject::Agent(ctx.agent_id);
access
.rbac_manager_mut()
.assign_role(subject, crate::access_manager::Role::Superuser);
let gate = AccessGate::new(
Arc::new(Mutex::new(access)),
Arc::new(ExecConfig {
allowlist_mode: AllowlistMode::Permissive, ..Default::default()
}),
Arc::new(NoOpAuditSink),
);
(gate, ctx)
}
fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
let mut access = AccessManager::new();
let ctx = AgentContext::test_fixture("test-agent");
let perms = AgentPermissions::for_new_agent("test-agent");
access.set_permissions(perms);
let subject = Subject::Agent(ctx.agent_id);
access
.rbac_manager_mut()
.assign_role(subject, crate::access_manager::Role::Superuser);
let config = ExecConfig {
allowlist_mode: AllowlistMode::Enforced,
allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
..Default::default()
};
let gate = AccessGate::new(
Arc::new(Mutex::new(access)),
Arc::new(config),
Arc::new(NoOpAuditSink),
);
(gate, ctx)
}
#[test]
fn test_tool_access_allowed() {
let (gate, ctx) = make_gate();
let result = gate.check(CheckRequest::Tool {
context: &ctx,
tool_name: "bash",
});
assert!(result.is_ok(), "bash should be allowed: {:?}", result);
}
#[test]
fn test_tool_access_unknown_agent_denied() {
let gate = AccessGate::new(
Arc::new(Mutex::new(AccessManager::new())), Arc::new(ExecConfig::default()),
Arc::new(NoOpAuditSink),
);
let ctx = AgentContext::test_fixture("unknown");
let result = gate.check(CheckRequest::Tool {
context: &ctx,
tool_name: "exec",
});
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.layer, DenyLayer::Permission);
}
#[test]
fn test_exec_allowed_permissive() {
let (gate, ctx) = make_gate();
let result = gate.check(CheckRequest::Exec {
context: &ctx,
binary: "echo",
args: &["hello".to_string()],
});
assert!(result.is_ok(), "echo should be allowed in permissive mode");
}
#[test]
fn test_exec_denied_enforced() {
let (gate, ctx) = make_enforced_gate(vec!["git"]);
let result = gate.check(CheckRequest::Exec {
context: &ctx,
binary: "rm",
args: &[],
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
}
#[test]
fn test_exec_metacharacters_denied() {
let (gate, ctx) = make_enforced_gate(vec!["echo"]);
let result = gate.check(CheckRequest::Exec {
context: &ctx,
binary: "echo",
args: &["foo; rm -rf /".to_string()],
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
}
#[test]
fn test_exec_path_traversal_denied() {
let (gate, ctx) = make_enforced_gate(vec!["cat"]);
let result = gate.check(CheckRequest::Exec {
context: &ctx,
binary: "cat",
args: &["../etc/passwd".to_string()],
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
}
#[test]
fn test_exec_enforced_allowed() {
let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
let result = gate.check(CheckRequest::Exec {
context: &ctx,
binary: "echo",
args: &["hello".to_string(), "world".to_string()],
});
assert!(result.is_ok(), "listed binary should be allowed");
}
#[test]
fn test_path_read_allowed() {
let (gate, ctx) = make_gate();
let result = gate.check(CheckRequest::Path {
context: &ctx,
path: Path::new("/workspace/project/file.rs"),
mode: PathMode::Read,
});
assert!(result.is_ok(), "workspace path should be readable");
}
#[test]
fn test_network_denied_by_default() {
let (gate, ctx) = make_gate();
let result = gate.check(CheckRequest::Network { context: &ctx });
assert!(result.is_err());
assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
}
#[test]
fn test_fork_denied_by_default() {
let (gate, ctx) = make_gate();
let result = gate.check(CheckRequest::Fork { context: &ctx });
assert!(result.is_err());
}
#[test]
fn test_deny_layer_display() {
assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
}
#[test]
fn test_no_metacharacters_in_clean_args() {
assert!(!has_metacharacters(&["hello".into(), "world".into()]));
}
#[test]
fn test_metacharacters_semicolon() {
assert!(has_metacharacters(&["foo;bar".into()]));
}
#[test]
fn test_metacharacters_pipe() {
assert!(has_metacharacters(&["a | b".into()]));
}
#[test]
fn test_metacharacters_dollar() {
assert!(has_metacharacters(&["$(whoami)".into()]));
}
#[test]
fn test_metacharacters_path_traversal() {
assert!(has_metacharacters(&["../etc/passwd".into()]));
}
#[test]
fn test_access_denied_display() {
let denied = AccessDenied {
agent: "test".into(),
resource: "exec".into(),
layer: DenyLayer::ExecPolicy,
reason: "not in allowlist".into(),
suggestion: Some("add to config".into()),
};
let s = format!("{}", denied);
assert!(s.contains("[ExecPolicy]"));
assert!(s.contains("not in allowlist"));
}
}