use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::error::KernelError;
use crate::process::{Pid, ProcessTable};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResourceLimits {
#[serde(default = "default_max_memory", alias = "maxMemoryBytes")]
pub max_memory_bytes: u64,
#[serde(default = "default_max_cpu", alias = "maxCpuTimeMs")]
pub max_cpu_time_ms: u64,
#[serde(default = "default_max_tool_calls", alias = "maxToolCalls")]
pub max_tool_calls: u64,
#[serde(default = "default_max_messages", alias = "maxMessages")]
pub max_messages: u64,
#[cfg(feature = "os-patterns")]
#[serde(default = "default_max_disk", alias = "maxDiskBytes")]
pub max_disk_bytes: u64,
}
fn default_max_memory() -> u64 {
256 * 1024 * 1024 }
fn default_max_cpu() -> u64 {
300_000 }
fn default_max_tool_calls() -> u64 {
1000
}
fn default_max_messages() -> u64 {
5000
}
#[cfg(feature = "os-patterns")]
fn default_max_disk() -> u64 {
100 * 1024 * 1024 }
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_memory_bytes: default_max_memory(),
max_cpu_time_ms: default_max_cpu(),
max_tool_calls: default_max_tool_calls(),
max_messages: default_max_messages(),
#[cfg(feature = "os-patterns")]
max_disk_bytes: default_max_disk(),
}
}
}
pub fn is_public_topic(topic: &str) -> bool {
topic == "public" || topic.starts_with("public.")
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum IpcScope {
#[default]
All,
ParentOnly,
Restricted(Vec<u64>),
Topic(Vec<String>),
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentCapabilities {
#[serde(default = "default_true", alias = "canSpawn")]
pub can_spawn: bool,
#[serde(default = "default_true", alias = "canIpc")]
pub can_ipc: bool,
#[serde(default = "default_true", alias = "canExecTools")]
pub can_exec_tools: bool,
#[serde(default, alias = "canNetwork")]
pub can_network: bool,
#[serde(default, alias = "ipcScope")]
pub ipc_scope: IpcScope,
#[serde(default, alias = "resourceLimits")]
pub resource_limits: ResourceLimits,
}
fn default_true() -> bool {
true
}
impl Default for AgentCapabilities {
fn default() -> Self {
Self {
can_spawn: true,
can_ipc: true,
can_exec_tools: true,
can_network: false,
ipc_scope: IpcScope::default(),
resource_limits: ResourceLimits::default(),
}
}
}
impl AgentCapabilities {
pub fn browser_default() -> Self {
Self {
can_spawn: false,
can_ipc: true,
can_exec_tools: true,
can_network: false,
ipc_scope: IpcScope::Restricted(vec![]),
resource_limits: ResourceLimits {
max_memory_bytes: 64 * 1024 * 1024, max_cpu_time_ms: 60_000, max_tool_calls: 200,
max_messages: 500,
#[cfg(feature = "os-patterns")]
max_disk_bytes: 10 * 1024 * 1024, },
}
}
pub fn can_message(&self, target_pid: u64) -> bool {
if !self.can_ipc {
return false;
}
match &self.ipc_scope {
IpcScope::All => true,
IpcScope::ParentOnly => false, IpcScope::Restricted(pids) => pids.contains(&target_pid),
IpcScope::Topic(_) => false, IpcScope::None => false,
}
}
pub fn can_topic(&self, topic: &str) -> bool {
if !self.can_ipc {
return false;
}
match &self.ipc_scope {
IpcScope::All => true,
IpcScope::Topic(topics) => topics.iter().any(|t| t == topic),
IpcScope::ParentOnly => true, IpcScope::Restricted(_) => is_public_topic(topic),
IpcScope::None => false,
}
}
pub fn within_limits(&self, memory: u64, cpu: u64, tools: u64, msgs: u64) -> bool {
memory <= self.resource_limits.max_memory_bytes
&& cpu <= self.resource_limits.max_cpu_time_ms
&& tools <= self.resource_limits.max_tool_calls
&& msgs <= self.resource_limits.max_messages
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxPolicy {
#[serde(default, alias = "allowShell")]
pub allow_shell: bool,
#[serde(default, alias = "allowNetwork")]
pub allow_network: bool,
#[serde(default, alias = "allowedPaths")]
pub allowed_paths: Vec<String>,
#[serde(default, alias = "deniedPaths")]
pub denied_paths: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolPermissions {
#[serde(default, alias = "tools")]
pub allow: Vec<String>,
#[serde(default, alias = "denyTools")]
pub deny: Vec<String>,
#[serde(default, alias = "serviceAccess")]
pub service_access: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum ResourceType {
Memory(u64),
CpuTime(u64),
ConcurrentTools(u32),
Messages(u64),
}
pub struct CapabilityChecker {
process_table: Arc<ProcessTable>,
}
impl CapabilityChecker {
pub fn new(process_table: Arc<ProcessTable>) -> Self {
Self { process_table }
}
pub fn check_tool_access(
&self,
pid: Pid,
tool_name: &str,
tool_permissions: Option<&ToolPermissions>,
sandbox: Option<&SandboxPolicy>,
) -> Result<(), KernelError> {
let entry = self
.process_table
.get(pid)
.ok_or(KernelError::ProcessNotFound { pid })?;
if !entry.capabilities.can_exec_tools {
return Err(KernelError::CapabilityDenied {
pid,
action: format!("execute tool '{tool_name}'"),
reason: "agent does not have can_exec_tools capability".into(),
});
}
if let Some(perms) = tool_permissions {
if perms.deny.iter().any(|d| d == tool_name) {
return Err(KernelError::CapabilityDenied {
pid,
action: format!("execute tool '{tool_name}'"),
reason: "tool is in the deny list".into(),
});
}
if !perms.allow.is_empty() && !perms.allow.iter().any(|a| a == tool_name) {
return Err(KernelError::CapabilityDenied {
pid,
action: format!("execute tool '{tool_name}'"),
reason: "tool is not in the allow list".into(),
});
}
}
if let Some(sb) = sandbox
&& is_shell_tool(tool_name)
&& !sb.allow_shell
{
return Err(KernelError::CapabilityDenied {
pid,
action: format!("execute shell tool '{tool_name}'"),
reason: "sandbox policy does not allow shell execution".into(),
});
}
Ok(())
}
pub fn check_ipc_target(&self, from_pid: Pid, to_pid: Pid) -> Result<(), KernelError> {
let entry = self
.process_table
.get(from_pid)
.ok_or(KernelError::ProcessNotFound { pid: from_pid })?;
if !entry.capabilities.can_ipc {
return Err(KernelError::CapabilityDenied {
pid: from_pid,
action: format!("send IPC message to PID {to_pid}"),
reason: "agent does not have IPC capability".into(),
});
}
if !entry.capabilities.can_message(to_pid) {
return Err(KernelError::CapabilityDenied {
pid: from_pid,
action: format!("send IPC message to PID {to_pid}"),
reason: format!(
"target PID {to_pid} is outside IPC scope {:?}",
entry.capabilities.ipc_scope
),
});
}
Ok(())
}
pub fn check_ipc_topic(&self, pid: Pid, topic: &str) -> Result<(), KernelError> {
let entry = self
.process_table
.get(pid)
.ok_or(KernelError::ProcessNotFound { pid })?;
if !entry.capabilities.can_topic(topic) {
return Err(KernelError::CapabilityDenied {
pid,
action: format!("access topic '{topic}'"),
reason: format!(
"topic '{topic}' is outside IPC scope {:?}",
entry.capabilities.ipc_scope
),
});
}
Ok(())
}
pub fn check_service_access(
&self,
pid: Pid,
service_name: &str,
tool_permissions: Option<&ToolPermissions>,
) -> Result<(), KernelError> {
let _entry = self
.process_table
.get(pid)
.ok_or(KernelError::ProcessNotFound { pid })?;
if let Some(perms) = tool_permissions
&& !perms.service_access.is_empty()
&& !perms.service_access.iter().any(|s| s == service_name)
{
return Err(KernelError::CapabilityDenied {
pid,
action: format!("access service '{service_name}'"),
reason: "service is not in the agent's service access list".into(),
});
}
Ok(())
}
pub fn check_resource_limit(
&self,
pid: Pid,
resource: &ResourceType,
) -> Result<(), KernelError> {
let entry = self
.process_table
.get(pid)
.ok_or(KernelError::ProcessNotFound { pid })?;
let limits = &entry.capabilities.resource_limits;
match resource {
ResourceType::Memory(bytes) => {
if *bytes > limits.max_memory_bytes {
return Err(KernelError::ResourceLimitExceeded {
pid,
resource: "memory".into(),
current: *bytes,
limit: limits.max_memory_bytes,
});
}
}
ResourceType::CpuTime(ms) => {
if *ms > limits.max_cpu_time_ms {
return Err(KernelError::ResourceLimitExceeded {
pid,
resource: "cpu_time".into(),
current: *ms,
limit: limits.max_cpu_time_ms,
});
}
}
ResourceType::ConcurrentTools(count) => {
if u64::from(*count) > limits.max_tool_calls {
return Err(KernelError::ResourceLimitExceeded {
pid,
resource: "concurrent_tools".into(),
current: u64::from(*count),
limit: limits.max_tool_calls,
});
}
}
ResourceType::Messages(count) => {
if *count > limits.max_messages {
return Err(KernelError::ResourceLimitExceeded {
pid,
resource: "messages".into(),
current: *count,
limit: limits.max_messages,
});
}
}
}
Ok(())
}
pub fn process_table(&self) -> &Arc<ProcessTable> {
&self.process_table
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityElevationRequest {
pub pid: u64,
pub current: AgentCapabilities,
pub requested: AgentCapabilities,
pub reason: String,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum ElevationResult {
Granted { new_capabilities: AgentCapabilities },
Denied { reason: String },
}
impl AgentCapabilities {
pub fn request_elevation(
current: &AgentCapabilities,
requested: &AgentCapabilities,
platform: &str,
) -> CapabilityElevationRequest {
CapabilityElevationRequest {
pid: 0, current: current.clone(),
requested: requested.clone(),
reason: format!("capability elevation for {platform} agent"),
}
}
#[cfg(feature = "exochain")]
pub fn request_elevation_gated(
_current: &AgentCapabilities,
requested: &AgentCapabilities,
platform: &str,
pid: u64,
gate: Option<&crate::gate::GovernanceGate>,
chain: Option<&crate::chain::ChainManager>,
) -> ElevationResult {
if let Some(gate) = gate {
use crate::gate::GateBackend;
let decision = gate.check(
&format!("pid:{pid}"),
"capability.elevate",
&serde_json::json!({
"pid": pid,
"platform": platform,
"can_spawn": requested.can_spawn,
"can_network": requested.can_network,
"effect": { "risk": 0.5, "security": 0.5 },
}),
);
if decision.is_deny() {
if let Some(cm) = chain {
cm.append(
"capability",
crate::chain::EVENT_KIND_CAPABILITY_ELEVATE,
Some(serde_json::json!({
"pid": pid,
"platform": platform,
"result": "denied",
"reason": "governance gate denied elevation",
})),
);
}
return ElevationResult::Denied {
reason: "governance denied capability elevation".into(),
};
}
}
if let Some(cm) = chain {
cm.append(
"capability",
crate::chain::EVENT_KIND_CAPABILITY_ELEVATE,
Some(serde_json::json!({
"pid": pid,
"platform": platform,
"result": "granted",
"can_spawn": requested.can_spawn,
"can_network": requested.can_network,
})),
);
}
ElevationResult::Granted {
new_capabilities: requested.clone(),
}
}
pub fn needs_elevation(platform: &str, requested: &AgentCapabilities) -> bool {
platform == "browser"
&& (requested.can_spawn
|| requested.can_network
|| !matches!(requested.ipc_scope, IpcScope::Restricted(_)))
}
}
fn is_shell_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"shell_exec" | "exec_shell" | "bash" | "command" | "run_command"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_capabilities() {
let caps = AgentCapabilities::default();
assert!(caps.can_spawn);
assert!(caps.can_ipc);
assert!(caps.can_exec_tools);
assert!(!caps.can_network);
assert_eq!(caps.ipc_scope, IpcScope::All);
}
#[test]
fn default_resource_limits() {
let limits = ResourceLimits::default();
assert_eq!(limits.max_memory_bytes, 256 * 1024 * 1024);
assert_eq!(limits.max_cpu_time_ms, 300_000);
assert_eq!(limits.max_tool_calls, 1000);
assert_eq!(limits.max_messages, 5000);
}
#[test]
fn can_message_all_scope() {
let caps = AgentCapabilities::default();
assert!(caps.can_message(1));
assert!(caps.can_message(999));
}
#[test]
fn can_message_restricted_scope() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![1, 2, 3]),
..Default::default()
};
assert!(caps.can_message(1));
assert!(caps.can_message(2));
assert!(!caps.can_message(4));
}
#[test]
fn can_message_none_scope() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::None,
..Default::default()
};
assert!(!caps.can_message(1));
}
#[test]
fn can_message_topic_scope_blocks_direct() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
..Default::default()
};
assert!(!caps.can_message(1));
assert!(!caps.can_message(999));
}
#[test]
fn can_topic_with_topic_scope() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
..Default::default()
};
assert!(caps.can_topic("build"));
assert!(caps.can_topic("deploy"));
assert!(!caps.can_topic("admin"));
}
#[test]
fn can_topic_with_all_scope() {
let caps = AgentCapabilities::default(); assert!(caps.can_topic("anything"));
}
#[test]
fn can_topic_with_none_scope() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::None,
..Default::default()
};
assert!(!caps.can_topic("build"));
}
#[test]
fn can_message_ipc_disabled() {
let caps = AgentCapabilities {
can_ipc: false,
..Default::default()
};
assert!(!caps.can_message(1));
}
#[test]
fn within_limits_ok() {
let caps = AgentCapabilities::default();
assert!(caps.within_limits(1000, 1000, 10, 10));
}
#[test]
fn within_limits_exceeded() {
let caps = AgentCapabilities {
resource_limits: ResourceLimits {
max_memory_bytes: 100,
max_cpu_time_ms: 100,
max_tool_calls: 5,
max_messages: 5,
},
..Default::default()
};
assert!(!caps.within_limits(200, 50, 3, 3)); assert!(!caps.within_limits(50, 200, 3, 3)); assert!(!caps.within_limits(50, 50, 10, 3)); assert!(!caps.within_limits(50, 50, 3, 10)); }
#[test]
fn serde_roundtrip_capabilities() {
let caps = AgentCapabilities {
can_spawn: false,
can_ipc: true,
can_exec_tools: false,
can_network: true,
ipc_scope: IpcScope::Restricted(vec![1, 2]),
resource_limits: ResourceLimits {
max_memory_bytes: 1024,
max_cpu_time_ms: 500,
max_tool_calls: 10,
max_messages: 20,
},
};
let json = serde_json::to_string(&caps).unwrap();
let restored: AgentCapabilities = serde_json::from_str(&json).unwrap();
assert_eq!(restored, caps);
}
#[test]
fn deserialize_empty_capabilities() {
let caps: AgentCapabilities = serde_json::from_str("{}").unwrap();
assert!(caps.can_spawn);
assert!(caps.can_ipc);
assert!(caps.can_exec_tools);
assert!(!caps.can_network);
}
#[test]
fn sandbox_policy_default() {
let sb = SandboxPolicy::default();
assert!(!sb.allow_shell);
assert!(!sb.allow_network);
assert!(sb.allowed_paths.is_empty());
assert!(sb.denied_paths.is_empty());
}
#[test]
fn sandbox_policy_serde_roundtrip() {
let sb = SandboxPolicy {
allow_shell: true,
allow_network: false,
allowed_paths: vec!["/workspace".into()],
denied_paths: vec!["/etc".into(), "/root".into()],
};
let json = serde_json::to_string(&sb).unwrap();
let restored: SandboxPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(restored, sb);
}
#[test]
fn tool_permissions_default() {
let perms = ToolPermissions::default();
assert!(perms.allow.is_empty());
assert!(perms.deny.is_empty());
assert!(perms.service_access.is_empty());
}
#[test]
fn tool_permissions_serde_roundtrip() {
let perms = ToolPermissions {
allow: vec!["read_file".into(), "write_file".into()],
deny: vec!["shell_exec".into()],
service_access: vec!["memory".into()],
};
let json = serde_json::to_string(&perms).unwrap();
let restored: ToolPermissions = serde_json::from_str(&json).unwrap();
assert_eq!(restored, perms);
}
use crate::process::{ProcessEntry, ProcessState, ResourceUsage};
use tokio_util::sync::CancellationToken;
fn make_checker_with_entry(caps: AgentCapabilities) -> (CapabilityChecker, Pid) {
let table = Arc::new(ProcessTable::new(16));
let entry = ProcessEntry {
pid: 0,
agent_id: "test-agent".to_owned(),
state: ProcessState::Running,
capabilities: caps,
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let pid = table.insert(entry).unwrap();
(CapabilityChecker::new(table), pid)
}
#[test]
fn checker_tool_access_allowed_by_default() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
assert!(
checker
.check_tool_access(pid, "read_file", None, None)
.is_ok()
);
}
#[test]
fn checker_tool_access_denied_no_exec_tools() {
let caps = AgentCapabilities {
can_exec_tools: false,
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
let result = checker.check_tool_access(pid, "read_file", None, None);
assert!(result.is_err());
}
#[test]
fn checker_tool_deny_list_blocks() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let perms = ToolPermissions {
deny: vec!["shell_exec".into()],
..Default::default()
};
let result = checker.check_tool_access(pid, "shell_exec", Some(&perms), None);
assert!(result.is_err());
}
#[test]
fn checker_tool_deny_overrides_allow() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let perms = ToolPermissions {
allow: vec!["shell_exec".into()],
deny: vec!["shell_exec".into()],
..Default::default()
};
let result = checker.check_tool_access(pid, "shell_exec", Some(&perms), None);
assert!(result.is_err());
}
#[test]
fn checker_tool_allow_list_restricts() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let perms = ToolPermissions {
allow: vec!["read_file".into(), "write_file".into()],
..Default::default()
};
assert!(
checker
.check_tool_access(pid, "read_file", Some(&perms), None)
.is_ok()
);
assert!(
checker
.check_tool_access(pid, "web_search", Some(&perms), None)
.is_err()
);
}
#[test]
fn checker_sandbox_blocks_shell() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let sb = SandboxPolicy {
allow_shell: false,
..Default::default()
};
let result = checker.check_tool_access(pid, "shell_exec", None, Some(&sb));
assert!(result.is_err());
}
#[test]
fn checker_sandbox_allows_shell() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let sb = SandboxPolicy {
allow_shell: true,
..Default::default()
};
assert!(
checker
.check_tool_access(pid, "shell_exec", None, Some(&sb))
.is_ok()
);
}
#[test]
fn checker_ipc_allowed() {
let table = Arc::new(ProcessTable::new(16));
let entry1 = ProcessEntry {
pid: 0,
agent_id: "sender".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(), resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let entry2 = ProcessEntry {
pid: 0,
agent_id: "receiver".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let pid1 = table.insert(entry1).unwrap();
let pid2 = table.insert(entry2).unwrap();
let checker = CapabilityChecker::new(table);
assert!(checker.check_ipc_target(pid1, pid2).is_ok());
}
#[test]
fn checker_ipc_denied_no_ipc() {
let caps = AgentCapabilities {
can_ipc: false,
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
let result = checker.check_ipc_target(pid, 999);
assert!(result.is_err());
}
#[test]
fn checker_ipc_denied_restricted_scope() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![5, 10]),
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
assert!(checker.check_ipc_target(pid, 5).is_ok());
assert!(checker.check_ipc_target(pid, 10).is_ok());
assert!(checker.check_ipc_target(pid, 15).is_err());
}
#[test]
fn checker_service_access_allowed_empty_list() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let perms = ToolPermissions::default();
assert!(
checker
.check_service_access(pid, "memory", Some(&perms))
.is_ok()
);
}
#[test]
fn checker_service_access_restricted() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
let perms = ToolPermissions {
service_access: vec!["memory".into(), "cron".into()],
..Default::default()
};
assert!(
checker
.check_service_access(pid, "memory", Some(&perms))
.is_ok()
);
assert!(
checker
.check_service_access(pid, "cron", Some(&perms))
.is_ok()
);
assert!(
checker
.check_service_access(pid, "network", Some(&perms))
.is_err()
);
}
#[test]
fn checker_resource_limit_memory_ok() {
let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
assert!(
checker
.check_resource_limit(pid, &ResourceType::Memory(1024))
.is_ok()
);
}
#[test]
fn checker_resource_limit_memory_exceeded() {
let caps = AgentCapabilities {
resource_limits: ResourceLimits {
max_memory_bytes: 100,
..Default::default()
},
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
let result = checker.check_resource_limit(pid, &ResourceType::Memory(200));
assert!(result.is_err());
}
#[test]
fn checker_resource_limit_cpu_exceeded() {
let caps = AgentCapabilities {
resource_limits: ResourceLimits {
max_cpu_time_ms: 100,
..Default::default()
},
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
let result = checker.check_resource_limit(pid, &ResourceType::CpuTime(200));
assert!(result.is_err());
}
#[test]
fn checker_resource_limit_messages_exceeded() {
let caps = AgentCapabilities {
resource_limits: ResourceLimits {
max_messages: 10,
..Default::default()
},
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
let result = checker.check_resource_limit(pid, &ResourceType::Messages(20));
assert!(result.is_err());
}
#[test]
fn checker_ipc_topic_allowed() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
assert!(checker.check_ipc_topic(pid, "build").is_ok());
assert!(checker.check_ipc_topic(pid, "deploy").is_ok());
assert!(checker.check_ipc_topic(pid, "admin").is_err());
}
#[test]
fn checker_ipc_topic_denied_for_direct_messaging() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Topic(vec!["build".into()]),
..Default::default()
};
let (checker, pid) = make_checker_with_entry(caps);
assert!(checker.check_ipc_target(pid, 999).is_err());
}
#[test]
fn checker_nonexistent_pid() {
let table = Arc::new(ProcessTable::new(16));
let checker = CapabilityChecker::new(table);
assert!(
checker
.check_tool_access(999, "read_file", None, None)
.is_err()
);
assert!(checker.check_ipc_target(999, 1).is_err());
assert!(
checker
.check_resource_limit(999, &ResourceType::Memory(0))
.is_err()
);
}
#[test]
fn browser_default_uses_restricted_ipc() {
let caps = AgentCapabilities::browser_default();
assert!(
matches!(caps.ipc_scope, IpcScope::Restricted(ref pids) if pids.is_empty()),
"browser agents must default to IpcScope::Restricted([])"
);
assert!(!caps.can_spawn, "browser agents must not spawn");
assert!(!caps.can_network, "browser agents must not access network");
assert!(caps.can_ipc, "browser agents need IPC for kernel comms");
assert!(caps.can_exec_tools, "browser agents need tool execution");
assert!(caps.resource_limits.max_memory_bytes < ResourceLimits::default().max_memory_bytes);
assert!(caps.resource_limits.max_cpu_time_ms < ResourceLimits::default().max_cpu_time_ms);
}
#[test]
fn browser_default_blocks_direct_messages() {
let caps = AgentCapabilities::browser_default();
assert!(!caps.can_message(1));
assert!(!caps.can_message(999));
}
#[test]
fn is_public_topic_prefix_rules() {
assert!(is_public_topic("public"));
assert!(is_public_topic("public.events"));
assert!(is_public_topic("public.health.status"));
assert!(!is_public_topic("publicx"));
assert!(!is_public_topic("admin.secrets"));
assert!(!is_public_topic("mesh.internal"));
assert!(!is_public_topic(""));
}
#[test]
fn browser_node_cannot_subscribe_to_non_public_topics() {
let caps = AgentCapabilities::browser_default();
assert!(
matches!(caps.ipc_scope, IpcScope::Restricted(ref pids) if pids.is_empty()),
"browser default must be IpcScope::Restricted([])"
);
assert!(caps.can_topic("public"));
assert!(caps.can_topic("public.events"));
assert!(caps.can_topic("public.health"));
assert!(!caps.can_topic("admin.secrets"));
assert!(!caps.can_topic("mesh.internal"));
assert!(!caps.can_topic("chain.append"));
assert!(!caps.can_topic("governance.root"));
let (checker, pid) = make_checker_with_entry(caps);
assert!(checker.check_ipc_topic(pid, "public.status").is_ok());
assert!(checker.check_ipc_topic(pid, "admin.secrets").is_err());
assert!(checker.check_ipc_topic(pid, "mesh.peer.join").is_err());
}
#[test]
fn restricted_scope_limits_topics_to_public() {
let caps = AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![1, 2]),
..Default::default()
};
assert!(caps.can_topic("public.feed"));
assert!(!caps.can_topic("private.feed"));
assert!(caps.can_message(1));
assert!(!caps.can_message(99));
}
#[test]
fn is_shell_tool_recognizes_variants() {
assert!(is_shell_tool("shell_exec"));
assert!(is_shell_tool("exec_shell"));
assert!(is_shell_tool("bash"));
assert!(is_shell_tool("command"));
assert!(is_shell_tool("run_command"));
assert!(!is_shell_tool("read_file"));
assert!(!is_shell_tool("web_search"));
}
#[test]
fn browser_elevation_needed_for_spawn() {
let requested = AgentCapabilities {
can_spawn: true,
..AgentCapabilities::browser_default()
};
assert!(AgentCapabilities::needs_elevation("browser", &requested));
}
#[test]
fn browser_elevation_needed_for_network() {
let requested = AgentCapabilities {
can_network: true,
..AgentCapabilities::browser_default()
};
assert!(AgentCapabilities::needs_elevation("browser", &requested));
}
#[test]
fn browser_elevation_not_needed_for_restricted() {
let requested = AgentCapabilities::browser_default();
assert!(!AgentCapabilities::needs_elevation("browser", &requested));
}
#[test]
fn non_browser_elevation_not_needed() {
let requested = AgentCapabilities {
can_spawn: true,
can_network: true,
ipc_scope: IpcScope::All,
..Default::default()
};
assert!(!AgentCapabilities::needs_elevation("native", &requested));
assert!(!AgentCapabilities::needs_elevation("wasi", &requested));
}
#[test]
fn browser_elevation_needed_for_ipc_all() {
let requested = AgentCapabilities {
ipc_scope: IpcScope::All,
..AgentCapabilities::browser_default()
};
assert!(AgentCapabilities::needs_elevation("browser", &requested));
}
#[test]
fn request_elevation_builds_request() {
let current = AgentCapabilities::browser_default();
let requested = AgentCapabilities {
can_network: true,
..AgentCapabilities::browser_default()
};
let req = AgentCapabilities::request_elevation(¤t, &requested, "browser");
assert_eq!(req.pid, 0);
assert!(!req.current.can_network);
assert!(req.requested.can_network);
assert!(req.reason.contains("browser"));
}
#[cfg(feature = "os-patterns")]
mod disk_quota_tests {
use super::*;
#[test]
fn default_disk_quota_is_100_mib() {
let limits = ResourceLimits::default();
assert_eq!(limits.max_disk_bytes, 100 * 1024 * 1024);
}
#[test]
fn browser_disk_quota_is_10_mib() {
let caps = AgentCapabilities::browser_default();
assert_eq!(caps.resource_limits.max_disk_bytes, 10 * 1024 * 1024);
}
#[test]
fn disk_quota_serde_roundtrip() {
let limits = ResourceLimits {
max_disk_bytes: 50 * 1024 * 1024,
..Default::default()
};
let json = serde_json::to_string(&limits).unwrap();
let restored: ResourceLimits = serde_json::from_str(&json).unwrap();
assert_eq!(restored.max_disk_bytes, 50 * 1024 * 1024);
}
#[test]
fn disk_quota_zero_means_unlimited() {
let limits = ResourceLimits {
max_disk_bytes: 0,
..Default::default()
};
assert_eq!(limits.max_disk_bytes, 0);
}
}
}