1use std::path::Path;
13use std::sync::Arc;
14
15use parking_lot::Mutex;
16
17use crate::security::audit_sink::{AuditEvent, AuditSink};
18use crate::security::capability::types::{ResourceRef, Rights};
19use crate::security::context::AgentContext;
20use crate::security::exec_policy::ExecPolicy;
21use crate::security::permissions::AgentPermissions;
22use crate::security::rbac::{Action, RbacManager, Role, Subject};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PathMode {
29 Read,
31 Write,
33}
34
35impl std::fmt::Display for PathMode {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 PathMode::Read => write!(f, "read"),
39 PathMode::Write => write!(f, "write"),
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DenyLayer {
49 Capability,
51 Rbac,
53 Permission,
55 ExecPolicy,
57}
58
59impl std::fmt::Display for DenyLayer {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 DenyLayer::Capability => write!(f, "CSpace"),
63 DenyLayer::Rbac => write!(f, "RBAC"),
64 DenyLayer::Permission => write!(f, "Permissions"),
65 DenyLayer::ExecPolicy => write!(f, "ExecPolicy"),
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
74pub struct AccessDenied {
75 pub agent: String,
77 pub resource: String,
79 pub layer: DenyLayer,
81 pub reason: String,
83 pub suggestion: Option<String>,
85}
86
87impl std::fmt::Display for AccessDenied {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(
90 f,
91 "[{}] {} — {}",
92 self.layer,
93 self.reason,
94 self.suggestion.as_deref().unwrap_or("")
95 )
96 }
97}
98
99#[derive(Debug)]
103pub enum CheckRequest<'a> {
104 Tool {
106 context: &'a AgentContext,
108 tool_name: &'a str,
110 },
111 Path {
113 context: &'a AgentContext,
115 path: &'a Path,
117 mode: PathMode,
119 },
120 Exec {
122 context: &'a AgentContext,
124 binary: &'a str,
126 args: &'a [String],
128 },
129 Network {
131 context: &'a AgentContext,
133 },
134 Fork {
136 context: &'a AgentContext,
138 },
139}
140
141impl<'a> CheckRequest<'a> {
142 pub fn agent_context(&self) -> &AgentContext {
144 match self {
145 CheckRequest::Tool { context, .. } => context,
146 CheckRequest::Path { context, .. } => context,
147 CheckRequest::Exec { context, .. } => context,
148 CheckRequest::Network { context } => context,
149 CheckRequest::Fork { context } => context,
150 }
151 }
152
153 pub fn resource(&self) -> &str {
155 match self {
156 CheckRequest::Tool { tool_name, .. } => tool_name,
157 CheckRequest::Path { path, .. } => path.to_str().unwrap_or("<invalid>"),
158 CheckRequest::Exec { binary, .. } => binary,
159 CheckRequest::Network { .. } => "<network>",
160 CheckRequest::Fork { .. } => "fork",
161 }
162 }
163}
164
165const SHELL_METACHARS: &[char] = &[
168 '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
169];
170
171fn has_metacharacters(args: &[String]) -> bool {
172 args.iter()
173 .any(|arg| arg.contains("..") || SHELL_METACHARS.iter().any(|&c| arg.contains(c)))
174}
175
176pub struct AccessGate {
182 permissions: Arc<Mutex<std::collections::HashMap<String, AgentPermissions>>>,
184 rbac: Arc<Mutex<RbacManager>>,
186 exec_policy: Arc<ExecPolicy>,
188 audit: Arc<dyn AuditSink>,
190}
191
192impl AccessGate {
193 pub fn new(
195 permissions: Arc<Mutex<std::collections::HashMap<String, AgentPermissions>>>,
196 rbac: Arc<Mutex<RbacManager>>,
197 exec_policy: Arc<ExecPolicy>,
198 audit: Arc<dyn AuditSink>,
199 ) -> Self {
200 Self {
201 permissions,
202 rbac,
203 exec_policy,
204 audit,
205 }
206 }
207
208 pub fn permissive() -> Self {
210 Self {
211 permissions: Arc::new(Mutex::new(std::collections::HashMap::new())),
212 rbac: Arc::new(Mutex::new(RbacManager::new())),
213 exec_policy: Arc::new(ExecPolicy::permissive()),
214 audit: Arc::new(crate::security::audit_sink::TracingAuditSink),
215 }
216 }
217
218 pub fn register_agent(&self, agent_name: &str, role: Role) {
220 self.permissions.lock().insert(
221 agent_name.to_string(),
222 AgentPermissions::for_new_agent(agent_name),
223 );
224 self.rbac
225 .lock()
226 .assign_role(Subject::User(agent_name.to_string()), role);
227 }
228
229 pub fn check(&self, req: CheckRequest<'_>) -> Result<(), AccessDenied> {
231 let result = match &req {
232 CheckRequest::Tool { context, tool_name } => self.check_tool(context, tool_name),
233 CheckRequest::Path {
234 context,
235 path,
236 mode,
237 } => self.check_path(context, path, *mode),
238 CheckRequest::Exec {
239 context,
240 binary,
241 args,
242 } => self.check_exec(context, binary, args),
243 CheckRequest::Network { context } => self.check_network(context),
244 CheckRequest::Fork { context } => self.check_fork(context),
245 };
246 self.record_check(&req, &result);
247 result
248 }
249
250 fn check_tool(&self, ctx: &AgentContext, tool: &str) -> Result<(), AccessDenied> {
251 let resource = ResourceRef::KernelDomain {
253 domain: tool.to_string(),
254 };
255 if !ctx.cspace.can(&resource, Rights::Execute) {
256 let always_on = [
257 "read", "write", "edit", "grep", "find", "ls", "bash", "exec",
258 ];
259 if !always_on.contains(&tool) {
260 return Err(AccessDenied {
261 agent: ctx.agent_name.clone(),
262 resource: tool.to_string(),
263 layer: DenyLayer::Capability,
264 reason: format!("No Execute capability for '{tool}' in CSpace"),
265 suggestion: Some(format!("Add '{tool}' capability to the agent's template.")),
266 });
267 }
268 }
269
270 let subject = Subject::User(ctx.agent_name.clone());
272 if !self
273 .rbac
274 .lock()
275 .check_permission(&subject, &Action::UseTool(tool.to_string()), tool)
276 {
277 return Err(AccessDenied {
278 agent: ctx.agent_name.clone(),
279 resource: tool.to_string(),
280 layer: DenyLayer::Rbac,
281 reason: format!("RBAC denied '{tool}' for '{}'", ctx.agent_name),
282 suggestion: None,
283 });
284 }
285
286 let perms = self.permissions.lock();
287 if let Some(p) = perms.get(&ctx.agent_name)
288 && !p.allowed_tools.contains(tool)
289 {
290 return Err(AccessDenied {
291 agent: ctx.agent_name.clone(),
292 resource: tool.to_string(),
293 layer: DenyLayer::Permission,
294 reason: format!("'{tool}' not in allowed_tools for '{}'", ctx.agent_name),
295 suggestion: None,
296 });
297 }
298
299 Ok(())
300 }
301
302 fn check_path(
303 &self,
304 ctx: &AgentContext,
305 path: &Path,
306 _mode: PathMode,
307 ) -> Result<(), AccessDenied> {
308 let path_str = path.to_string_lossy();
309
310 let perms = self.permissions.lock();
312 if let Some(p) = perms.get(&ctx.agent_name)
313 && p.is_path_denied(&path_str)
314 {
315 return Err(AccessDenied {
316 agent: ctx.agent_name.clone(),
317 resource: path_str.to_string(),
318 layer: DenyLayer::Permission,
319 reason: format!("Path '{path_str}' is in denied_paths"),
320 suggestion: None,
321 });
322 }
323
324 Ok(())
325 }
326
327 fn check_exec(
328 &self,
329 ctx: &AgentContext,
330 binary: &str,
331 args: &[String],
332 ) -> Result<(), AccessDenied> {
333 if !self.exec_policy.is_binary_allowed(binary) {
335 return Err(AccessDenied {
336 agent: ctx.agent_name.clone(),
337 resource: binary.to_string(),
338 layer: DenyLayer::ExecPolicy,
339 reason: format!("Binary '{binary}' not in allowlist"),
340 suggestion: Some("Add to ExecPolicy.allowed_commands.".into()),
341 });
342 }
343
344 if has_metacharacters(args) {
345 return Err(AccessDenied {
346 agent: ctx.agent_name.clone(),
347 resource: binary.to_string(),
348 layer: DenyLayer::ExecPolicy,
349 reason: "Arguments contain shell metacharacters or path traversal".into(),
350 suggestion: None,
351 });
352 }
353
354 Ok(())
355 }
356
357 fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
358 let perms = self.permissions.lock();
359 if let Some(p) = perms.get(&ctx.agent_name)
360 && !p.network_access
361 {
362 return Err(AccessDenied {
363 agent: ctx.agent_name.clone(),
364 resource: "<network>".into(),
365 layer: DenyLayer::Permission,
366 reason: "Network access disabled".into(),
367 suggestion: Some("Set network_access to true.".into()),
368 });
369 }
370 Ok(())
371 }
372
373 fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
374 let perms = self.permissions.lock();
375 if let Some(p) = perms.get(&ctx.agent_name)
376 && !p.can_fork
377 {
378 return Err(AccessDenied {
379 agent: ctx.agent_name.clone(),
380 resource: "fork".into(),
381 layer: DenyLayer::Permission,
382 reason: "Fork not allowed".into(),
383 suggestion: Some("Set can_fork to true.".into()),
384 });
385 }
386 Ok(())
387 }
388
389 fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
390 let ctx = req.agent_context();
391 let ts = chrono::Utc::now();
392 let event = match result {
393 Ok(()) => AuditEvent::ToolAccess {
394 timestamp: ts,
395 agent: ctx.agent_name.clone(),
396 tool: req.resource().to_string(),
397 allowed: true,
398 layer: None,
399 reason: None,
400 },
401 Err(denied) => AuditEvent::ToolAccess {
402 timestamp: ts,
403 agent: ctx.agent_name.clone(),
404 tool: req.resource().to_string(),
405 allowed: false,
406 layer: Some(denied.layer.to_string()),
407 reason: Some(denied.reason.clone()),
408 },
409 };
410 self.audit.record(event);
411 }
412}
413
414impl std::fmt::Debug for AccessGate {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 f.debug_struct("AccessGate").finish()
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 struct NoOpSink;
425 impl AuditSink for NoOpSink {
426 fn record(&self, _event: AuditEvent) {}
427 }
428
429 fn make_gate() -> (AccessGate, AgentContext) {
430 let perms = Arc::new(Mutex::new(std::collections::HashMap::new()));
431 let rbac = Arc::new(Mutex::new(RbacManager::new()));
432 let ctx = AgentContext::from_template("test-agent", "standard");
433
434 perms.lock().insert(
435 "test-agent".into(),
436 AgentPermissions::for_new_agent("test-agent"),
437 );
438 rbac.lock()
439 .assign_role(Subject::User("test-agent".into()), Role::Superuser);
440
441 let gate = AccessGate::new(
442 perms,
443 rbac,
444 Arc::new(ExecPolicy::permissive()),
445 Arc::new(NoOpSink),
446 );
447 (gate, ctx)
448 }
449
450 #[test]
451 fn test_tool_allowed() {
452 let (gate, ctx) = make_gate();
453 assert!(
454 gate.check(CheckRequest::Tool {
455 context: &ctx,
456 tool_name: "bash"
457 })
458 .is_ok()
459 );
460 }
461
462 #[test]
463 fn test_exec_metacharacters_denied() {
464 let (gate, ctx) = make_gate();
465 let result = gate.check(CheckRequest::Exec {
466 context: &ctx,
467 binary: "echo",
468 args: &["foo; rm -rf /".to_string()],
469 });
470 assert!(result.is_err());
471 assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
472 }
473
474 #[test]
475 fn test_network_denied_by_default() {
476 let (gate, ctx) = make_gate();
477 let result = gate.check(CheckRequest::Network { context: &ctx });
478 assert!(result.is_err());
479 }
480
481 #[test]
482 fn test_fork_denied_by_default() {
483 let (gate, ctx) = make_gate();
484 let result = gate.check(CheckRequest::Fork { context: &ctx });
485 assert!(result.is_err());
486 }
487
488 #[test]
489 fn test_path_denied_in_deny_list() {
490 let (gate, ctx) = make_gate();
491 let result = gate.check(CheckRequest::Path {
492 context: &ctx,
493 path: Path::new("/etc/passwd"),
494 mode: PathMode::Read,
495 });
496 assert!(result.is_err());
497 }
498
499 #[test]
500 fn test_permissive_gate() {
501 let gate = AccessGate::permissive();
502 let _ctx = AgentContext::from_template("dev", "worker");
503 drop(gate);
506 }
507
508 #[test]
509 fn test_deny_layer_display() {
510 assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
511 assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
512 }
513
514 #[test]
515 fn test_access_denied_display() {
516 let d = AccessDenied {
517 agent: "test".into(),
518 resource: "exec".into(),
519 layer: DenyLayer::ExecPolicy,
520 reason: "not allowed".into(),
521 suggestion: Some("add it".into()),
522 };
523 assert!(format!("{}", d).contains("[ExecPolicy]"));
524 }
525}