1use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::KernelError;
12use crate::process::{Pid, ProcessTable};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct ResourceLimits {
20 #[serde(default = "default_max_memory", alias = "maxMemoryBytes")]
22 pub max_memory_bytes: u64,
23
24 #[serde(default = "default_max_cpu", alias = "maxCpuTimeMs")]
26 pub max_cpu_time_ms: u64,
27
28 #[serde(default = "default_max_tool_calls", alias = "maxToolCalls")]
30 pub max_tool_calls: u64,
31
32 #[serde(default = "default_max_messages", alias = "maxMessages")]
34 pub max_messages: u64,
35
36 #[cfg(feature = "os-patterns")]
41 #[serde(default = "default_max_disk", alias = "maxDiskBytes")]
42 pub max_disk_bytes: u64,
43}
44
45fn default_max_memory() -> u64 {
46 256 * 1024 * 1024 }
48
49fn default_max_cpu() -> u64 {
50 300_000 }
52
53fn default_max_tool_calls() -> u64 {
54 1000
55}
56
57fn default_max_messages() -> u64 {
58 5000
59}
60
61#[cfg(feature = "os-patterns")]
62fn default_max_disk() -> u64 {
63 100 * 1024 * 1024 }
65
66impl Default for ResourceLimits {
67 fn default() -> Self {
68 Self {
69 max_memory_bytes: default_max_memory(),
70 max_cpu_time_ms: default_max_cpu(),
71 max_tool_calls: default_max_tool_calls(),
72 max_messages: default_max_messages(),
73 #[cfg(feature = "os-patterns")]
74 max_disk_bytes: default_max_disk(),
75 }
76 }
77}
78
79pub fn is_public_topic(topic: &str) -> bool {
85 topic == "public" || topic.starts_with("public.")
86}
87
88#[non_exhaustive]
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub enum IpcScope {
92 #[default]
94 All,
95 ParentOnly,
97 Restricted(Vec<u64>),
102 Topic(Vec<String>),
104 None,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct AgentCapabilities {
113 #[serde(default = "default_true", alias = "canSpawn")]
115 pub can_spawn: bool,
116
117 #[serde(default = "default_true", alias = "canIpc")]
119 pub can_ipc: bool,
120
121 #[serde(default = "default_true", alias = "canExecTools")]
123 pub can_exec_tools: bool,
124
125 #[serde(default, alias = "canNetwork")]
127 pub can_network: bool,
128
129 #[serde(default, alias = "ipcScope")]
131 pub ipc_scope: IpcScope,
132
133 #[serde(default, alias = "resourceLimits")]
135 pub resource_limits: ResourceLimits,
136}
137
138fn default_true() -> bool {
139 true
140}
141
142impl Default for AgentCapabilities {
143 fn default() -> Self {
144 Self {
145 can_spawn: true,
146 can_ipc: true,
147 can_exec_tools: true,
148 can_network: false,
149 ipc_scope: IpcScope::default(),
150 resource_limits: ResourceLimits::default(),
151 }
152 }
153}
154
155impl AgentCapabilities {
156 pub fn browser_default() -> Self {
162 Self {
163 can_spawn: false,
164 can_ipc: true,
165 can_exec_tools: true,
166 can_network: false,
167 ipc_scope: IpcScope::Restricted(vec![]),
168 resource_limits: ResourceLimits {
169 max_memory_bytes: 64 * 1024 * 1024, max_cpu_time_ms: 60_000, max_tool_calls: 200,
172 max_messages: 500,
173 #[cfg(feature = "os-patterns")]
174 max_disk_bytes: 10 * 1024 * 1024, },
176 }
177 }
178
179 pub fn can_message(&self, target_pid: u64) -> bool {
181 if !self.can_ipc {
182 return false;
183 }
184 match &self.ipc_scope {
185 IpcScope::All => true,
186 IpcScope::ParentOnly => false, IpcScope::Restricted(pids) => pids.contains(&target_pid),
188 IpcScope::Topic(_) => false, IpcScope::None => false,
190 }
191 }
192
193 pub fn can_topic(&self, topic: &str) -> bool {
198 if !self.can_ipc {
199 return false;
200 }
201 match &self.ipc_scope {
202 IpcScope::All => true,
203 IpcScope::Topic(topics) => topics.iter().any(|t| t == topic),
204 IpcScope::ParentOnly => true, IpcScope::Restricted(_) => is_public_topic(topic),
206 IpcScope::None => false,
207 }
208 }
209
210 pub fn within_limits(&self, memory: u64, cpu: u64, tools: u64, msgs: u64) -> bool {
212 memory <= self.resource_limits.max_memory_bytes
213 && cpu <= self.resource_limits.max_cpu_time_ms
214 && tools <= self.resource_limits.max_tool_calls
215 && msgs <= self.resource_limits.max_messages
216 }
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
224pub struct SandboxPolicy {
225 #[serde(default, alias = "allowShell")]
227 pub allow_shell: bool,
228
229 #[serde(default, alias = "allowNetwork")]
231 pub allow_network: bool,
232
233 #[serde(default, alias = "allowedPaths")]
235 pub allowed_paths: Vec<String>,
236
237 #[serde(default, alias = "deniedPaths")]
239 pub denied_paths: Vec<String>,
240}
241
242#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
247pub struct ToolPermissions {
248 #[serde(default, alias = "tools")]
250 pub allow: Vec<String>,
251
252 #[serde(default, alias = "denyTools")]
254 pub deny: Vec<String>,
255
256 #[serde(default, alias = "serviceAccess")]
258 pub service_access: Vec<String>,
259}
260
261#[non_exhaustive]
265#[derive(Debug, Clone)]
266pub enum ResourceType {
267 Memory(u64),
269 CpuTime(u64),
271 ConcurrentTools(u32),
273 Messages(u64),
275}
276
277pub struct CapabilityChecker {
285 process_table: Arc<ProcessTable>,
286}
287
288impl CapabilityChecker {
289 pub fn new(process_table: Arc<ProcessTable>) -> Self {
291 Self { process_table }
292 }
293
294 pub fn check_tool_access(
309 &self,
310 pid: Pid,
311 tool_name: &str,
312 tool_permissions: Option<&ToolPermissions>,
313 sandbox: Option<&SandboxPolicy>,
314 ) -> Result<(), KernelError> {
315 let entry = self
316 .process_table
317 .get(pid)
318 .ok_or(KernelError::ProcessNotFound { pid })?;
319
320 if !entry.capabilities.can_exec_tools {
322 return Err(KernelError::CapabilityDenied {
323 pid,
324 action: format!("execute tool '{tool_name}'"),
325 reason: "agent does not have can_exec_tools capability".into(),
326 });
327 }
328
329 if let Some(perms) = tool_permissions {
331 if perms.deny.iter().any(|d| d == tool_name) {
332 return Err(KernelError::CapabilityDenied {
333 pid,
334 action: format!("execute tool '{tool_name}'"),
335 reason: "tool is in the deny list".into(),
336 });
337 }
338
339 if !perms.allow.is_empty() && !perms.allow.iter().any(|a| a == tool_name) {
341 return Err(KernelError::CapabilityDenied {
342 pid,
343 action: format!("execute tool '{tool_name}'"),
344 reason: "tool is not in the allow list".into(),
345 });
346 }
347 }
348
349 if let Some(sb) = sandbox
351 && is_shell_tool(tool_name)
352 && !sb.allow_shell
353 {
354 return Err(KernelError::CapabilityDenied {
355 pid,
356 action: format!("execute shell tool '{tool_name}'"),
357 reason: "sandbox policy does not allow shell execution".into(),
358 });
359 }
360
361 Ok(())
362 }
363
364 pub fn check_ipc_target(&self, from_pid: Pid, to_pid: Pid) -> Result<(), KernelError> {
374 let entry = self
375 .process_table
376 .get(from_pid)
377 .ok_or(KernelError::ProcessNotFound { pid: from_pid })?;
378
379 if !entry.capabilities.can_ipc {
380 return Err(KernelError::CapabilityDenied {
381 pid: from_pid,
382 action: format!("send IPC message to PID {to_pid}"),
383 reason: "agent does not have IPC capability".into(),
384 });
385 }
386
387 if !entry.capabilities.can_message(to_pid) {
388 return Err(KernelError::CapabilityDenied {
389 pid: from_pid,
390 action: format!("send IPC message to PID {to_pid}"),
391 reason: format!(
392 "target PID {to_pid} is outside IPC scope {:?}",
393 entry.capabilities.ipc_scope
394 ),
395 });
396 }
397
398 Ok(())
399 }
400
401 pub fn check_ipc_topic(&self, pid: Pid, topic: &str) -> Result<(), KernelError> {
408 let entry = self
409 .process_table
410 .get(pid)
411 .ok_or(KernelError::ProcessNotFound { pid })?;
412
413 if !entry.capabilities.can_topic(topic) {
414 return Err(KernelError::CapabilityDenied {
415 pid,
416 action: format!("access topic '{topic}'"),
417 reason: format!(
418 "topic '{topic}' is outside IPC scope {:?}",
419 entry.capabilities.ipc_scope
420 ),
421 });
422 }
423
424 Ok(())
425 }
426
427 pub fn check_service_access(
437 &self,
438 pid: Pid,
439 service_name: &str,
440 tool_permissions: Option<&ToolPermissions>,
441 ) -> Result<(), KernelError> {
442 let _entry = self
444 .process_table
445 .get(pid)
446 .ok_or(KernelError::ProcessNotFound { pid })?;
447
448 if let Some(perms) = tool_permissions
449 && !perms.service_access.is_empty()
450 && !perms.service_access.iter().any(|s| s == service_name)
451 {
452 return Err(KernelError::CapabilityDenied {
453 pid,
454 action: format!("access service '{service_name}'"),
455 reason: "service is not in the agent's service access list".into(),
456 });
457 }
458
459 Ok(())
460 }
461
462 pub fn check_resource_limit(
469 &self,
470 pid: Pid,
471 resource: &ResourceType,
472 ) -> Result<(), KernelError> {
473 let entry = self
474 .process_table
475 .get(pid)
476 .ok_or(KernelError::ProcessNotFound { pid })?;
477
478 let limits = &entry.capabilities.resource_limits;
479
480 match resource {
481 ResourceType::Memory(bytes) => {
482 if *bytes > limits.max_memory_bytes {
483 return Err(KernelError::ResourceLimitExceeded {
484 pid,
485 resource: "memory".into(),
486 current: *bytes,
487 limit: limits.max_memory_bytes,
488 });
489 }
490 }
491 ResourceType::CpuTime(ms) => {
492 if *ms > limits.max_cpu_time_ms {
493 return Err(KernelError::ResourceLimitExceeded {
494 pid,
495 resource: "cpu_time".into(),
496 current: *ms,
497 limit: limits.max_cpu_time_ms,
498 });
499 }
500 }
501 ResourceType::ConcurrentTools(count) => {
502 if u64::from(*count) > limits.max_tool_calls {
503 return Err(KernelError::ResourceLimitExceeded {
504 pid,
505 resource: "concurrent_tools".into(),
506 current: u64::from(*count),
507 limit: limits.max_tool_calls,
508 });
509 }
510 }
511 ResourceType::Messages(count) => {
512 if *count > limits.max_messages {
513 return Err(KernelError::ResourceLimitExceeded {
514 pid,
515 resource: "messages".into(),
516 current: *count,
517 limit: limits.max_messages,
518 });
519 }
520 }
521 }
522
523 Ok(())
524 }
525
526 pub fn process_table(&self) -> &Arc<ProcessTable> {
528 &self.process_table
529 }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct CapabilityElevationRequest {
540 pub pid: u64,
542 pub current: AgentCapabilities,
544 pub requested: AgentCapabilities,
546 pub reason: String,
548}
549
550#[non_exhaustive]
552#[derive(Debug, Clone)]
553pub enum ElevationResult {
554 Granted { new_capabilities: AgentCapabilities },
556 Denied { reason: String },
558}
559
560impl AgentCapabilities {
561 pub fn request_elevation(
564 current: &AgentCapabilities,
565 requested: &AgentCapabilities,
566 platform: &str,
567 ) -> CapabilityElevationRequest {
568 CapabilityElevationRequest {
569 pid: 0, current: current.clone(),
571 requested: requested.clone(),
572 reason: format!("capability elevation for {platform} agent"),
573 }
574 }
575
576 #[cfg(feature = "exochain")]
582 pub fn request_elevation_gated(
583 _current: &AgentCapabilities,
584 requested: &AgentCapabilities,
585 platform: &str,
586 pid: u64,
587 gate: Option<&crate::gate::GovernanceGate>,
588 chain: Option<&crate::chain::ChainManager>,
589 ) -> ElevationResult {
590 if let Some(gate) = gate {
592 use crate::gate::GateBackend;
593 let decision = gate.check(
594 &format!("pid:{pid}"),
595 "capability.elevate",
596 &serde_json::json!({
597 "pid": pid,
598 "platform": platform,
599 "can_spawn": requested.can_spawn,
600 "can_network": requested.can_network,
601 "effect": { "risk": 0.5, "security": 0.5 },
602 }),
603 );
604 if decision.is_deny() {
605 if let Some(cm) = chain {
607 cm.append(
608 "capability",
609 crate::chain::EVENT_KIND_CAPABILITY_ELEVATE,
610 Some(serde_json::json!({
611 "pid": pid,
612 "platform": platform,
613 "result": "denied",
614 "reason": "governance gate denied elevation",
615 })),
616 );
617 }
618 return ElevationResult::Denied {
619 reason: "governance denied capability elevation".into(),
620 };
621 }
622 }
623
624 if let Some(cm) = chain {
626 cm.append(
627 "capability",
628 crate::chain::EVENT_KIND_CAPABILITY_ELEVATE,
629 Some(serde_json::json!({
630 "pid": pid,
631 "platform": platform,
632 "result": "granted",
633 "can_spawn": requested.can_spawn,
634 "can_network": requested.can_network,
635 })),
636 );
637 }
638
639 ElevationResult::Granted {
640 new_capabilities: requested.clone(),
641 }
642 }
643
644 pub fn needs_elevation(platform: &str, requested: &AgentCapabilities) -> bool {
650 platform == "browser"
651 && (requested.can_spawn
652 || requested.can_network
653 || !matches!(requested.ipc_scope, IpcScope::Restricted(_)))
654 }
655}
656
657fn is_shell_tool(tool_name: &str) -> bool {
659 matches!(
660 tool_name,
661 "shell_exec" | "exec_shell" | "bash" | "command" | "run_command"
662 )
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn default_capabilities() {
671 let caps = AgentCapabilities::default();
672 assert!(caps.can_spawn);
673 assert!(caps.can_ipc);
674 assert!(caps.can_exec_tools);
675 assert!(!caps.can_network);
676 assert_eq!(caps.ipc_scope, IpcScope::All);
677 }
678
679 #[test]
680 fn default_resource_limits() {
681 let limits = ResourceLimits::default();
682 assert_eq!(limits.max_memory_bytes, 256 * 1024 * 1024);
683 assert_eq!(limits.max_cpu_time_ms, 300_000);
684 assert_eq!(limits.max_tool_calls, 1000);
685 assert_eq!(limits.max_messages, 5000);
686 }
687
688 #[test]
689 fn can_message_all_scope() {
690 let caps = AgentCapabilities::default();
691 assert!(caps.can_message(1));
692 assert!(caps.can_message(999));
693 }
694
695 #[test]
696 fn can_message_restricted_scope() {
697 let caps = AgentCapabilities {
698 ipc_scope: IpcScope::Restricted(vec![1, 2, 3]),
699 ..Default::default()
700 };
701 assert!(caps.can_message(1));
702 assert!(caps.can_message(2));
703 assert!(!caps.can_message(4));
704 }
705
706 #[test]
707 fn can_message_none_scope() {
708 let caps = AgentCapabilities {
709 ipc_scope: IpcScope::None,
710 ..Default::default()
711 };
712 assert!(!caps.can_message(1));
713 }
714
715 #[test]
716 fn can_message_topic_scope_blocks_direct() {
717 let caps = AgentCapabilities {
718 ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
719 ..Default::default()
720 };
721 assert!(!caps.can_message(1));
723 assert!(!caps.can_message(999));
724 }
725
726 #[test]
727 fn can_topic_with_topic_scope() {
728 let caps = AgentCapabilities {
729 ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
730 ..Default::default()
731 };
732 assert!(caps.can_topic("build"));
733 assert!(caps.can_topic("deploy"));
734 assert!(!caps.can_topic("admin"));
735 }
736
737 #[test]
738 fn can_topic_with_all_scope() {
739 let caps = AgentCapabilities::default(); assert!(caps.can_topic("anything"));
741 }
742
743 #[test]
744 fn can_topic_with_none_scope() {
745 let caps = AgentCapabilities {
746 ipc_scope: IpcScope::None,
747 ..Default::default()
748 };
749 assert!(!caps.can_topic("build"));
750 }
751
752 #[test]
753 fn can_message_ipc_disabled() {
754 let caps = AgentCapabilities {
755 can_ipc: false,
756 ..Default::default()
757 };
758 assert!(!caps.can_message(1));
759 }
760
761 #[test]
762 fn within_limits_ok() {
763 let caps = AgentCapabilities::default();
764 assert!(caps.within_limits(1000, 1000, 10, 10));
765 }
766
767 #[test]
768 fn within_limits_exceeded() {
769 let caps = AgentCapabilities {
770 resource_limits: ResourceLimits {
771 max_memory_bytes: 100,
772 max_cpu_time_ms: 100,
773 max_tool_calls: 5,
774 max_messages: 5,
775 },
776 ..Default::default()
777 };
778 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)); }
783
784 #[test]
785 fn serde_roundtrip_capabilities() {
786 let caps = AgentCapabilities {
787 can_spawn: false,
788 can_ipc: true,
789 can_exec_tools: false,
790 can_network: true,
791 ipc_scope: IpcScope::Restricted(vec![1, 2]),
792 resource_limits: ResourceLimits {
793 max_memory_bytes: 1024,
794 max_cpu_time_ms: 500,
795 max_tool_calls: 10,
796 max_messages: 20,
797 },
798 };
799 let json = serde_json::to_string(&caps).unwrap();
800 let restored: AgentCapabilities = serde_json::from_str(&json).unwrap();
801 assert_eq!(restored, caps);
802 }
803
804 #[test]
805 fn deserialize_empty_capabilities() {
806 let caps: AgentCapabilities = serde_json::from_str("{}").unwrap();
807 assert!(caps.can_spawn);
808 assert!(caps.can_ipc);
809 assert!(caps.can_exec_tools);
810 assert!(!caps.can_network);
811 }
812
813 #[test]
816 fn sandbox_policy_default() {
817 let sb = SandboxPolicy::default();
818 assert!(!sb.allow_shell);
819 assert!(!sb.allow_network);
820 assert!(sb.allowed_paths.is_empty());
821 assert!(sb.denied_paths.is_empty());
822 }
823
824 #[test]
825 fn sandbox_policy_serde_roundtrip() {
826 let sb = SandboxPolicy {
827 allow_shell: true,
828 allow_network: false,
829 allowed_paths: vec!["/workspace".into()],
830 denied_paths: vec!["/etc".into(), "/root".into()],
831 };
832 let json = serde_json::to_string(&sb).unwrap();
833 let restored: SandboxPolicy = serde_json::from_str(&json).unwrap();
834 assert_eq!(restored, sb);
835 }
836
837 #[test]
840 fn tool_permissions_default() {
841 let perms = ToolPermissions::default();
842 assert!(perms.allow.is_empty());
843 assert!(perms.deny.is_empty());
844 assert!(perms.service_access.is_empty());
845 }
846
847 #[test]
848 fn tool_permissions_serde_roundtrip() {
849 let perms = ToolPermissions {
850 allow: vec!["read_file".into(), "write_file".into()],
851 deny: vec!["shell_exec".into()],
852 service_access: vec!["memory".into()],
853 };
854 let json = serde_json::to_string(&perms).unwrap();
855 let restored: ToolPermissions = serde_json::from_str(&json).unwrap();
856 assert_eq!(restored, perms);
857 }
858
859 use crate::process::{ProcessEntry, ProcessState, ResourceUsage};
862 use tokio_util::sync::CancellationToken;
863
864 fn make_checker_with_entry(caps: AgentCapabilities) -> (CapabilityChecker, Pid) {
865 let table = Arc::new(ProcessTable::new(16));
866 let entry = ProcessEntry {
867 pid: 0,
868 agent_id: "test-agent".to_owned(),
869 state: ProcessState::Running,
870 capabilities: caps,
871 resource_usage: ResourceUsage::default(),
872 cancel_token: CancellationToken::new(),
873 parent_pid: None,
874 };
875 let pid = table.insert(entry).unwrap();
876 (CapabilityChecker::new(table), pid)
877 }
878
879 #[test]
880 fn checker_tool_access_allowed_by_default() {
881 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
882 assert!(
883 checker
884 .check_tool_access(pid, "read_file", None, None)
885 .is_ok()
886 );
887 }
888
889 #[test]
890 fn checker_tool_access_denied_no_exec_tools() {
891 let caps = AgentCapabilities {
892 can_exec_tools: false,
893 ..Default::default()
894 };
895 let (checker, pid) = make_checker_with_entry(caps);
896 let result = checker.check_tool_access(pid, "read_file", None, None);
897 assert!(result.is_err());
898 }
899
900 #[test]
901 fn checker_tool_deny_list_blocks() {
902 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
903 let perms = ToolPermissions {
904 deny: vec!["shell_exec".into()],
905 ..Default::default()
906 };
907 let result = checker.check_tool_access(pid, "shell_exec", Some(&perms), None);
908 assert!(result.is_err());
909 }
910
911 #[test]
912 fn checker_tool_deny_overrides_allow() {
913 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
914 let perms = ToolPermissions {
915 allow: vec!["shell_exec".into()],
916 deny: vec!["shell_exec".into()],
917 ..Default::default()
918 };
919 let result = checker.check_tool_access(pid, "shell_exec", Some(&perms), None);
920 assert!(result.is_err());
921 }
922
923 #[test]
924 fn checker_tool_allow_list_restricts() {
925 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
926 let perms = ToolPermissions {
927 allow: vec!["read_file".into(), "write_file".into()],
928 ..Default::default()
929 };
930 assert!(
931 checker
932 .check_tool_access(pid, "read_file", Some(&perms), None)
933 .is_ok()
934 );
935 assert!(
936 checker
937 .check_tool_access(pid, "web_search", Some(&perms), None)
938 .is_err()
939 );
940 }
941
942 #[test]
943 fn checker_sandbox_blocks_shell() {
944 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
945 let sb = SandboxPolicy {
946 allow_shell: false,
947 ..Default::default()
948 };
949 let result = checker.check_tool_access(pid, "shell_exec", None, Some(&sb));
950 assert!(result.is_err());
951 }
952
953 #[test]
954 fn checker_sandbox_allows_shell() {
955 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
956 let sb = SandboxPolicy {
957 allow_shell: true,
958 ..Default::default()
959 };
960 assert!(
961 checker
962 .check_tool_access(pid, "shell_exec", None, Some(&sb))
963 .is_ok()
964 );
965 }
966
967 #[test]
968 fn checker_ipc_allowed() {
969 let table = Arc::new(ProcessTable::new(16));
970 let entry1 = ProcessEntry {
971 pid: 0,
972 agent_id: "sender".to_owned(),
973 state: ProcessState::Running,
974 capabilities: AgentCapabilities::default(), resource_usage: ResourceUsage::default(),
976 cancel_token: CancellationToken::new(),
977 parent_pid: None,
978 };
979 let entry2 = ProcessEntry {
980 pid: 0,
981 agent_id: "receiver".to_owned(),
982 state: ProcessState::Running,
983 capabilities: AgentCapabilities::default(),
984 resource_usage: ResourceUsage::default(),
985 cancel_token: CancellationToken::new(),
986 parent_pid: None,
987 };
988 let pid1 = table.insert(entry1).unwrap();
989 let pid2 = table.insert(entry2).unwrap();
990
991 let checker = CapabilityChecker::new(table);
992 assert!(checker.check_ipc_target(pid1, pid2).is_ok());
993 }
994
995 #[test]
996 fn checker_ipc_denied_no_ipc() {
997 let caps = AgentCapabilities {
998 can_ipc: false,
999 ..Default::default()
1000 };
1001 let (checker, pid) = make_checker_with_entry(caps);
1002 let result = checker.check_ipc_target(pid, 999);
1003 assert!(result.is_err());
1004 }
1005
1006 #[test]
1007 fn checker_ipc_denied_restricted_scope() {
1008 let caps = AgentCapabilities {
1009 ipc_scope: IpcScope::Restricted(vec![5, 10]),
1010 ..Default::default()
1011 };
1012 let (checker, pid) = make_checker_with_entry(caps);
1013 assert!(checker.check_ipc_target(pid, 5).is_ok());
1014 assert!(checker.check_ipc_target(pid, 10).is_ok());
1015 assert!(checker.check_ipc_target(pid, 15).is_err());
1016 }
1017
1018 #[test]
1019 fn checker_service_access_allowed_empty_list() {
1020 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
1021 let perms = ToolPermissions::default();
1023 assert!(
1024 checker
1025 .check_service_access(pid, "memory", Some(&perms))
1026 .is_ok()
1027 );
1028 }
1029
1030 #[test]
1031 fn checker_service_access_restricted() {
1032 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
1033 let perms = ToolPermissions {
1034 service_access: vec!["memory".into(), "cron".into()],
1035 ..Default::default()
1036 };
1037 assert!(
1038 checker
1039 .check_service_access(pid, "memory", Some(&perms))
1040 .is_ok()
1041 );
1042 assert!(
1043 checker
1044 .check_service_access(pid, "cron", Some(&perms))
1045 .is_ok()
1046 );
1047 assert!(
1048 checker
1049 .check_service_access(pid, "network", Some(&perms))
1050 .is_err()
1051 );
1052 }
1053
1054 #[test]
1055 fn checker_resource_limit_memory_ok() {
1056 let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
1057 assert!(
1058 checker
1059 .check_resource_limit(pid, &ResourceType::Memory(1024))
1060 .is_ok()
1061 );
1062 }
1063
1064 #[test]
1065 fn checker_resource_limit_memory_exceeded() {
1066 let caps = AgentCapabilities {
1067 resource_limits: ResourceLimits {
1068 max_memory_bytes: 100,
1069 ..Default::default()
1070 },
1071 ..Default::default()
1072 };
1073 let (checker, pid) = make_checker_with_entry(caps);
1074 let result = checker.check_resource_limit(pid, &ResourceType::Memory(200));
1075 assert!(result.is_err());
1076 }
1077
1078 #[test]
1079 fn checker_resource_limit_cpu_exceeded() {
1080 let caps = AgentCapabilities {
1081 resource_limits: ResourceLimits {
1082 max_cpu_time_ms: 100,
1083 ..Default::default()
1084 },
1085 ..Default::default()
1086 };
1087 let (checker, pid) = make_checker_with_entry(caps);
1088 let result = checker.check_resource_limit(pid, &ResourceType::CpuTime(200));
1089 assert!(result.is_err());
1090 }
1091
1092 #[test]
1093 fn checker_resource_limit_messages_exceeded() {
1094 let caps = AgentCapabilities {
1095 resource_limits: ResourceLimits {
1096 max_messages: 10,
1097 ..Default::default()
1098 },
1099 ..Default::default()
1100 };
1101 let (checker, pid) = make_checker_with_entry(caps);
1102 let result = checker.check_resource_limit(pid, &ResourceType::Messages(20));
1103 assert!(result.is_err());
1104 }
1105
1106 #[test]
1107 fn checker_ipc_topic_allowed() {
1108 let caps = AgentCapabilities {
1109 ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
1110 ..Default::default()
1111 };
1112 let (checker, pid) = make_checker_with_entry(caps);
1113 assert!(checker.check_ipc_topic(pid, "build").is_ok());
1114 assert!(checker.check_ipc_topic(pid, "deploy").is_ok());
1115 assert!(checker.check_ipc_topic(pid, "admin").is_err());
1116 }
1117
1118 #[test]
1119 fn checker_ipc_topic_denied_for_direct_messaging() {
1120 let caps = AgentCapabilities {
1121 ipc_scope: IpcScope::Topic(vec!["build".into()]),
1122 ..Default::default()
1123 };
1124 let (checker, pid) = make_checker_with_entry(caps);
1125 assert!(checker.check_ipc_target(pid, 999).is_err());
1127 }
1128
1129 #[test]
1130 fn checker_nonexistent_pid() {
1131 let table = Arc::new(ProcessTable::new(16));
1132 let checker = CapabilityChecker::new(table);
1133 assert!(
1134 checker
1135 .check_tool_access(999, "read_file", None, None)
1136 .is_err()
1137 );
1138 assert!(checker.check_ipc_target(999, 1).is_err());
1139 assert!(
1140 checker
1141 .check_resource_limit(999, &ResourceType::Memory(0))
1142 .is_err()
1143 );
1144 }
1145
1146 #[test]
1147 fn browser_default_uses_restricted_ipc() {
1148 let caps = AgentCapabilities::browser_default();
1149 assert!(
1150 matches!(caps.ipc_scope, IpcScope::Restricted(ref pids) if pids.is_empty()),
1151 "browser agents must default to IpcScope::Restricted([])"
1152 );
1153 assert!(!caps.can_spawn, "browser agents must not spawn");
1154 assert!(!caps.can_network, "browser agents must not access network");
1155 assert!(caps.can_ipc, "browser agents need IPC for kernel comms");
1156 assert!(caps.can_exec_tools, "browser agents need tool execution");
1157 assert!(caps.resource_limits.max_memory_bytes < ResourceLimits::default().max_memory_bytes);
1159 assert!(caps.resource_limits.max_cpu_time_ms < ResourceLimits::default().max_cpu_time_ms);
1160 }
1161
1162 #[test]
1163 fn browser_default_blocks_direct_messages() {
1164 let caps = AgentCapabilities::browser_default();
1165 assert!(!caps.can_message(1));
1167 assert!(!caps.can_message(999));
1168 }
1169
1170 #[test]
1171 fn is_public_topic_prefix_rules() {
1172 assert!(is_public_topic("public"));
1173 assert!(is_public_topic("public.events"));
1174 assert!(is_public_topic("public.health.status"));
1175 assert!(!is_public_topic("publicx"));
1176 assert!(!is_public_topic("admin.secrets"));
1177 assert!(!is_public_topic("mesh.internal"));
1178 assert!(!is_public_topic(""));
1179 }
1180
1181 #[test]
1184 fn browser_node_cannot_subscribe_to_non_public_topics() {
1185 let caps = AgentCapabilities::browser_default();
1186 assert!(
1187 matches!(caps.ipc_scope, IpcScope::Restricted(ref pids) if pids.is_empty()),
1188 "browser default must be IpcScope::Restricted([])"
1189 );
1190
1191 assert!(caps.can_topic("public"));
1193 assert!(caps.can_topic("public.events"));
1194 assert!(caps.can_topic("public.health"));
1195
1196 assert!(!caps.can_topic("admin.secrets"));
1198 assert!(!caps.can_topic("mesh.internal"));
1199 assert!(!caps.can_topic("chain.append"));
1200 assert!(!caps.can_topic("governance.root"));
1201
1202 let (checker, pid) = make_checker_with_entry(caps);
1204 assert!(checker.check_ipc_topic(pid, "public.status").is_ok());
1205 assert!(checker.check_ipc_topic(pid, "admin.secrets").is_err());
1206 assert!(checker.check_ipc_topic(pid, "mesh.peer.join").is_err());
1207 }
1208
1209 #[test]
1210 fn restricted_scope_limits_topics_to_public() {
1211 let caps = AgentCapabilities {
1212 ipc_scope: IpcScope::Restricted(vec![1, 2]),
1213 ..Default::default()
1214 };
1215 assert!(caps.can_topic("public.feed"));
1216 assert!(!caps.can_topic("private.feed"));
1217 assert!(caps.can_message(1));
1219 assert!(!caps.can_message(99));
1220 }
1221
1222 #[test]
1223 fn is_shell_tool_recognizes_variants() {
1224 assert!(is_shell_tool("shell_exec"));
1225 assert!(is_shell_tool("exec_shell"));
1226 assert!(is_shell_tool("bash"));
1227 assert!(is_shell_tool("command"));
1228 assert!(is_shell_tool("run_command"));
1229 assert!(!is_shell_tool("read_file"));
1230 assert!(!is_shell_tool("web_search"));
1231 }
1232
1233 #[test]
1236 fn browser_elevation_needed_for_spawn() {
1237 let requested = AgentCapabilities {
1238 can_spawn: true,
1239 ..AgentCapabilities::browser_default()
1240 };
1241 assert!(AgentCapabilities::needs_elevation("browser", &requested));
1242 }
1243
1244 #[test]
1245 fn browser_elevation_needed_for_network() {
1246 let requested = AgentCapabilities {
1247 can_network: true,
1248 ..AgentCapabilities::browser_default()
1249 };
1250 assert!(AgentCapabilities::needs_elevation("browser", &requested));
1251 }
1252
1253 #[test]
1254 fn browser_elevation_not_needed_for_restricted() {
1255 let requested = AgentCapabilities::browser_default();
1257 assert!(!AgentCapabilities::needs_elevation("browser", &requested));
1258 }
1259
1260 #[test]
1261 fn non_browser_elevation_not_needed() {
1262 let requested = AgentCapabilities {
1264 can_spawn: true,
1265 can_network: true,
1266 ipc_scope: IpcScope::All,
1267 ..Default::default()
1268 };
1269 assert!(!AgentCapabilities::needs_elevation("native", &requested));
1270 assert!(!AgentCapabilities::needs_elevation("wasi", &requested));
1271 }
1272
1273 #[test]
1274 fn browser_elevation_needed_for_ipc_all() {
1275 let requested = AgentCapabilities {
1276 ipc_scope: IpcScope::All,
1277 ..AgentCapabilities::browser_default()
1278 };
1279 assert!(AgentCapabilities::needs_elevation("browser", &requested));
1280 }
1281
1282 #[test]
1283 fn request_elevation_builds_request() {
1284 let current = AgentCapabilities::browser_default();
1285 let requested = AgentCapabilities {
1286 can_network: true,
1287 ..AgentCapabilities::browser_default()
1288 };
1289 let req = AgentCapabilities::request_elevation(¤t, &requested, "browser");
1290 assert_eq!(req.pid, 0);
1291 assert!(!req.current.can_network);
1292 assert!(req.requested.can_network);
1293 assert!(req.reason.contains("browser"));
1294 }
1295
1296 #[cfg(feature = "os-patterns")]
1299 mod disk_quota_tests {
1300 use super::*;
1301
1302 #[test]
1303 fn default_disk_quota_is_100_mib() {
1304 let limits = ResourceLimits::default();
1305 assert_eq!(limits.max_disk_bytes, 100 * 1024 * 1024);
1306 }
1307
1308 #[test]
1309 fn browser_disk_quota_is_10_mib() {
1310 let caps = AgentCapabilities::browser_default();
1311 assert_eq!(caps.resource_limits.max_disk_bytes, 10 * 1024 * 1024);
1312 }
1313
1314 #[test]
1315 fn disk_quota_serde_roundtrip() {
1316 let limits = ResourceLimits {
1317 max_disk_bytes: 50 * 1024 * 1024,
1318 ..Default::default()
1319 };
1320 let json = serde_json::to_string(&limits).unwrap();
1321 let restored: ResourceLimits = serde_json::from_str(&json).unwrap();
1322 assert_eq!(restored.max_disk_bytes, 50 * 1024 * 1024);
1323 }
1324
1325 #[test]
1326 fn disk_quota_zero_means_unlimited() {
1327 let limits = ResourceLimits {
1328 max_disk_bytes: 0,
1329 ..Default::default()
1330 };
1331 assert_eq!(limits.max_disk_bytes, 0);
1332 }
1333 }
1334}