use chrono::{DateTime, Utc};
use glob::Pattern;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPermissions {
pub agent_name: String,
#[serde(default)]
pub allowed_tools: HashSet<String>,
#[serde(default)]
pub allowed_paths: Vec<String>,
#[serde(default)]
pub denied_paths: Vec<String>,
#[serde(default)]
pub network_access: bool,
#[serde(default)]
pub max_execution_time_secs: u64,
#[serde(default)]
pub max_memory_mb: u64,
#[serde(default)]
pub can_fork: bool,
}
impl Default for AgentPermissions {
fn default() -> Self {
Self {
agent_name: String::new(),
allowed_tools: ["read", "write", "edit", "bash", "grep", "find", "exec"]
.iter()
.map(|s| s.to_string())
.collect(),
allowed_paths: vec!["/workspace/**".to_string()],
denied_paths: vec![
"/etc/**".to_string(),
"/root/**".to_string(),
"/sys/**".to_string(),
"/proc/**".to_string(),
".oxios/**".to_string(),
],
network_access: false,
max_execution_time_secs: 300,
max_memory_mb: 512,
can_fork: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionUpdate {
#[serde(default)]
pub allowed_tools: Option<HashSet<String>>,
#[serde(default)]
pub allowed_paths: Option<Vec<String>>,
#[serde(default)]
pub denied_paths: Option<Vec<String>>,
#[serde(default)]
pub network_access: Option<bool>,
#[serde(default)]
pub max_execution_time_secs: Option<u64>,
#[serde(default)]
pub max_memory_mb: Option<u64>,
#[serde(default)]
pub can_fork: Option<bool>,
}
impl PermissionUpdate {
pub fn apply(&self, perms: &mut AgentPermissions) {
if let Some(tools) = &self.allowed_tools {
perms.allowed_tools = tools.clone();
}
if let Some(paths) = &self.allowed_paths {
perms.allowed_paths = paths.clone();
}
if let Some(paths) = &self.denied_paths {
perms.denied_paths = paths.clone();
}
if let Some(v) = self.network_access {
perms.network_access = v;
}
if let Some(v) = self.max_execution_time_secs {
perms.max_execution_time_secs = v;
}
if let Some(v) = self.max_memory_mb {
perms.max_memory_mb = v;
}
if let Some(v) = self.can_fork {
perms.can_fork = v;
}
}
}
impl AgentPermissions {
pub fn for_new_agent(agent_name: &str) -> Self {
Self {
agent_name: agent_name.to_string(),
..Default::default()
}
}
pub fn allow_tool(&mut self, tool: &str) {
self.allowed_tools.insert(tool.to_string());
}
pub fn deny_tool(&mut self, tool: &str) {
self.allowed_tools.remove(tool);
}
pub fn allow_path(&mut self, path: &str) {
if !self.allowed_paths.contains(&path.to_string()) {
self.allowed_paths.push(path.to_string());
}
}
pub fn deny_path(&mut self, path: &str) {
if !self.denied_paths.contains(&path.to_string()) {
self.denied_paths.push(path.to_string());
}
}
pub fn enable_network(&mut self) {
self.network_access = true;
}
pub fn enable_forking(&mut self) {
self.can_fork = true;
}
pub(crate) fn is_path_denied(&self, path: &str) -> bool {
for pattern in &self.denied_paths {
if let Ok(p) = Pattern::new(pattern) {
if p.matches(path) {
return true;
}
}
}
false
}
pub(crate) fn is_path_allowed(&self, path: &str) -> bool {
for pattern in &self.allowed_paths {
if let Ok(p) = Pattern::new(pattern) {
if p.matches(path) {
return true;
}
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub agent_name: String,
pub action: String,
pub resource: String,
pub allowed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl AuditEntry {
pub fn new(
agent_name: &str,
action: &str,
resource: &str,
allowed: bool,
reason: Option<String>,
) -> Self {
Self {
timestamp: Utc::now(),
agent_name: agent_name.to_string(),
action: action.to_string(),
resource: resource.to_string(),
allowed,
reason,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_permissions_has_basic_tools() {
let perms = AgentPermissions::default();
assert!(perms.allowed_tools.contains("read"));
assert!(perms.allowed_tools.contains("write"));
assert!(perms.allowed_tools.contains("bash"));
assert!(perms.allowed_tools.contains("exec"));
assert!(!perms.network_access);
assert!(!perms.can_fork);
assert_eq!(perms.max_execution_time_secs, 300);
assert_eq!(perms.max_memory_mb, 512);
}
#[test]
fn test_default_permissions_denies_sensitive_paths() {
let perms = AgentPermissions::default();
assert!(perms.is_path_denied("/etc/passwd"));
assert!(perms.is_path_denied("/root/.ssh/id_rsa"));
assert!(perms.is_path_denied("/proc/self/environ"));
assert!(perms.is_path_denied("/sys/kernel/addr"));
assert!(perms.is_path_denied(".oxios/config.toml"));
}
#[test]
fn test_default_permissions_allows_workspace() {
let perms = AgentPermissions::default();
assert!(perms.is_path_allowed("/workspace/src/main.rs"));
assert!(perms.is_path_allowed("/workspace/README.md"));
assert!(!perms.is_path_allowed("/tmp/evil"));
}
#[test]
fn test_for_new_agent_sets_name() {
let perms = AgentPermissions::for_new_agent("test-agent");
assert_eq!(perms.agent_name, "test-agent");
assert!(perms.allowed_tools.contains("read"));
}
#[test]
fn test_allow_and_deny_tool() {
let mut perms = AgentPermissions::for_new_agent("a");
perms.allow_tool("custom_tool");
assert!(perms.allowed_tools.contains("custom_tool"));
perms.deny_tool("bash");
assert!(!perms.allowed_tools.contains("bash"));
perms.deny_tool("nonexistent");
}
#[test]
fn test_allow_and_deny_path_deduplication() {
let mut perms = AgentPermissions::for_new_agent("a");
perms.allow_path("/data/**");
perms.allow_path("/data/**"); assert_eq!(
perms
.allowed_paths
.iter()
.filter(|p| **p == "/data/**")
.count(),
1
);
perms.deny_path("/secret/**");
perms.deny_path("/secret/**"); assert_eq!(
perms
.denied_paths
.iter()
.filter(|p| **p == "/secret/**")
.count(),
1
);
}
#[test]
fn test_enable_network_and_forking() {
let mut perms = AgentPermissions::for_new_agent("a");
assert!(!perms.network_access);
assert!(!perms.can_fork);
perms.enable_network();
assert!(perms.network_access);
perms.enable_forking();
assert!(perms.can_fork);
}
#[test]
fn test_denied_overrides_allowed() {
let mut perms = AgentPermissions::for_new_agent("a");
perms.allowed_paths = vec!["/workspace/**".to_string()];
perms.denied_paths = vec!["/workspace/secret/**".to_string()];
assert!(perms.is_path_allowed("/workspace/secret/key.pem"));
assert!(perms.is_path_denied("/workspace/secret/key.pem"));
}
#[test]
fn test_invalid_glob_pattern() {
let mut perms = AgentPermissions::for_new_agent("a");
perms.allowed_paths = vec!["[invalid".to_string()];
assert!(!perms.is_path_allowed("/anything"));
}
#[test]
fn test_permission_update_partial() {
let mut perms = AgentPermissions::for_new_agent("a");
let original_tools = perms.allowed_tools.clone();
let update = PermissionUpdate {
network_access: Some(true),
max_execution_time_secs: Some(600),
..Default::default()
};
update.apply(&mut perms);
assert!(perms.network_access);
assert_eq!(perms.max_execution_time_secs, 600);
assert_eq!(perms.allowed_tools, original_tools);
assert!(!perms.can_fork);
}
#[test]
fn test_permission_update_full_replace() {
let mut perms = AgentPermissions::for_new_agent("a");
let update = PermissionUpdate {
allowed_tools: Some(HashSet::from(["read".to_string()])),
allowed_paths: Some(vec!["/safe/**".to_string()]),
denied_paths: Some(vec![]),
network_access: Some(true),
max_execution_time_secs: Some(0),
max_memory_mb: Some(1024),
can_fork: Some(true),
};
update.apply(&mut perms);
assert_eq!(perms.allowed_tools.len(), 1);
assert!(perms.allowed_tools.contains("read"));
assert_eq!(perms.allowed_paths, vec!["/safe/**"]);
assert!(perms.denied_paths.is_empty());
assert!(perms.network_access);
assert!(perms.can_fork);
assert_eq!(perms.max_memory_mb, 1024);
}
#[test]
fn test_audit_entry_new_allowed() {
let entry = AuditEntry::new("agent-1", "use_tool", "bash", true, None);
assert_eq!(entry.agent_name, "agent-1");
assert_eq!(entry.action, "use_tool");
assert_eq!(entry.resource, "bash");
assert!(entry.allowed);
assert!(entry.reason.is_none());
}
#[test]
fn test_audit_entry_new_denied_with_reason() {
let entry = AuditEntry::new(
"rogue-agent",
"access_path",
"/etc/shadow",
false,
Some("path not in allowed list".to_string()),
);
assert!(!entry.allowed);
assert_eq!(entry.reason.as_deref(), Some("path not in allowed list"));
}
#[test]
fn test_permissions_serialization_roundtrip() {
let mut perms = AgentPermissions::for_new_agent("serializer");
perms.enable_network();
perms.allow_tool("curl");
let json = serde_json::to_string(&perms).unwrap();
let restored: AgentPermissions = serde_json::from_str(&json).unwrap();
assert_eq!(restored.agent_name, "serializer");
assert!(restored.network_access);
assert!(restored.allowed_tools.contains("curl"));
}
#[test]
fn test_audit_entry_serialization_roundtrip() {
let entry = AuditEntry::new(
"test",
"network_request",
"https://example.com",
false,
Some("network not allowed".to_string()),
);
let json = serde_json::to_string(&entry).unwrap();
let restored: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(restored.agent_name, entry.agent_name);
assert_eq!(restored.action, entry.action);
assert_eq!(restored.allowed, entry.allowed);
assert_eq!(restored.reason, entry.reason);
}
#[test]
fn test_permission_update_default_is_noop() {
let mut perms = AgentPermissions::for_new_agent("a");
let snapshot = perms.clone();
let update = PermissionUpdate::default();
update.apply(&mut perms);
assert_eq!(perms.agent_name, snapshot.agent_name);
assert_eq!(perms.allowed_tools, snapshot.allowed_tools);
assert_eq!(perms.allowed_paths, snapshot.allowed_paths);
assert_eq!(perms.denied_paths, snapshot.denied_paths);
assert_eq!(perms.network_access, snapshot.network_access);
assert_eq!(
perms.max_execution_time_secs,
snapshot.max_execution_time_secs
);
assert_eq!(perms.max_memory_mb, snapshot.max_memory_mb);
assert_eq!(perms.can_fork, snapshot.can_fork);
}
}