Skip to main content

clawft_kernel/
capability.rs

1//! Agent capabilities and resource limits.
2//!
3//! Defines the permission model for kernel-managed agents. Each agent
4//! process has an [`AgentCapabilities`] that governs what IPC scopes,
5//! tool categories, and resource budgets the agent is allowed.
6
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::KernelError;
12use crate::process::{Pid, ProcessTable};
13
14/// Resource limits for an agent process.
15///
16/// Enforced by the kernel's process table when updating resource
17/// usage counters.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct ResourceLimits {
20    /// Maximum memory (in bytes) the agent is allowed to consume.
21    #[serde(default = "default_max_memory", alias = "maxMemoryBytes")]
22    pub max_memory_bytes: u64,
23
24    /// Maximum CPU time (in milliseconds) before the agent is killed.
25    #[serde(default = "default_max_cpu", alias = "maxCpuTimeMs")]
26    pub max_cpu_time_ms: u64,
27
28    /// Maximum number of tool calls the agent may make.
29    #[serde(default = "default_max_tool_calls", alias = "maxToolCalls")]
30    pub max_tool_calls: u64,
31
32    /// Maximum number of IPC messages the agent may send.
33    #[serde(default = "default_max_messages", alias = "maxMessages")]
34    pub max_messages: u64,
35
36    /// Maximum disk usage in bytes for this agent (K1-G4).
37    ///
38    /// Enforced when writing to the resource tree under `/agents/{agent_id}/`.
39    /// Default: 100 MiB. Set to 0 for unlimited.
40    #[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 // 256 MiB
47}
48
49fn default_max_cpu() -> u64 {
50    300_000 // 5 minutes
51}
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 // 100 MiB
64}
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
79/// IPC scope defining which message targets an agent may communicate with.
80#[non_exhaustive]
81#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
82pub enum IpcScope {
83    /// Agent may communicate with all other agents.
84    #[default]
85    All,
86    /// Agent may only communicate with its parent.
87    ParentOnly,
88    /// Agent may communicate with a specified set of PIDs.
89    Restricted(Vec<u64>),
90    /// Agent may only publish/subscribe to specified topics (no direct PID messaging).
91    Topic(Vec<String>),
92    /// Agent may not send IPC messages.
93    None,
94}
95
96/// Capabilities assigned to an agent process.
97///
98/// Governs what the agent is allowed to do within the kernel.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct AgentCapabilities {
101    /// Whether the agent can spawn child processes.
102    #[serde(default = "default_true", alias = "canSpawn")]
103    pub can_spawn: bool,
104
105    /// Whether the agent can send/receive IPC messages.
106    #[serde(default = "default_true", alias = "canIpc")]
107    pub can_ipc: bool,
108
109    /// Whether the agent can execute tools.
110    #[serde(default = "default_true", alias = "canExecTools")]
111    pub can_exec_tools: bool,
112
113    /// Whether the agent can make network requests.
114    #[serde(default, alias = "canNetwork")]
115    pub can_network: bool,
116
117    /// IPC scope restriction.
118    #[serde(default, alias = "ipcScope")]
119    pub ipc_scope: IpcScope,
120
121    /// Resource limits for this agent.
122    #[serde(default, alias = "resourceLimits")]
123    pub resource_limits: ResourceLimits,
124}
125
126fn default_true() -> bool {
127    true
128}
129
130impl Default for AgentCapabilities {
131    fn default() -> Self {
132        Self {
133            can_spawn: true,
134            can_ipc: true,
135            can_exec_tools: true,
136            can_network: false,
137            ipc_scope: IpcScope::default(),
138            resource_limits: ResourceLimits::default(),
139        }
140    }
141}
142
143impl AgentCapabilities {
144    /// Create capabilities for a browser-platform agent.
145    ///
146    /// Browser agents default to restricted IPC scope (empty allow-list),
147    /// no spawning, no network access, and no shell — maximising the
148    /// sandbox surface for untrusted in-browser code.
149    pub fn browser_default() -> Self {
150        Self {
151            can_spawn: false,
152            can_ipc: true,
153            can_exec_tools: true,
154            can_network: false,
155            ipc_scope: IpcScope::Restricted(vec![]),
156            resource_limits: ResourceLimits {
157                max_memory_bytes: 64 * 1024 * 1024, // 64 MiB
158                max_cpu_time_ms: 60_000,             // 1 minute
159                max_tool_calls: 200,
160                max_messages: 500,
161                #[cfg(feature = "os-patterns")]
162                max_disk_bytes: 10 * 1024 * 1024, // 10 MiB for browser agents
163            },
164        }
165    }
166
167    /// Check whether the agent is allowed to send a message to the given PID.
168    pub fn can_message(&self, target_pid: u64) -> bool {
169        if !self.can_ipc {
170            return false;
171        }
172        match &self.ipc_scope {
173            IpcScope::All => true,
174            IpcScope::ParentOnly => false, // Caller must check parent separately
175            IpcScope::Restricted(pids) => pids.contains(&target_pid),
176            IpcScope::Topic(_) => false, // Topic-scoped agents cannot direct-message PIDs
177            IpcScope::None => false,
178        }
179    }
180
181    /// Check whether the agent is allowed to publish/subscribe to a topic.
182    pub fn can_topic(&self, topic: &str) -> bool {
183        if !self.can_ipc {
184            return false;
185        }
186        match &self.ipc_scope {
187            IpcScope::All => true,
188            IpcScope::Topic(topics) => topics.iter().any(|t| t == topic),
189            IpcScope::ParentOnly | IpcScope::Restricted(_) => true, // topic access not restricted
190            IpcScope::None => false,
191        }
192    }
193
194    /// Check whether the resource usage is within limits.
195    pub fn within_limits(&self, memory: u64, cpu: u64, tools: u64, msgs: u64) -> bool {
196        memory <= self.resource_limits.max_memory_bytes
197            && cpu <= self.resource_limits.max_cpu_time_ms
198            && tools <= self.resource_limits.max_tool_calls
199            && msgs <= self.resource_limits.max_messages
200    }
201}
202
203/// Sandbox policy governing filesystem and shell access.
204///
205/// Controls whether an agent can execute shell commands, access
206/// the network, and which filesystem paths are allowed or denied.
207#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
208pub struct SandboxPolicy {
209    /// Whether the agent may execute shell commands.
210    #[serde(default, alias = "allowShell")]
211    pub allow_shell: bool,
212
213    /// Whether the agent may make network requests.
214    #[serde(default, alias = "allowNetwork")]
215    pub allow_network: bool,
216
217    /// Filesystem paths the agent is allowed to access.
218    #[serde(default, alias = "allowedPaths")]
219    pub allowed_paths: Vec<String>,
220
221    /// Filesystem paths the agent is explicitly denied from accessing.
222    #[serde(default, alias = "deniedPaths")]
223    pub denied_paths: Vec<String>,
224}
225
226/// Tool-level permission configuration.
227///
228/// An allow/deny list model where deny overrides allow.
229/// Empty `allow` means all tools permitted (unless explicitly denied).
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
231pub struct ToolPermissions {
232    /// Tools the agent is allowed to use (empty = all allowed).
233    #[serde(default, alias = "tools")]
234    pub allow: Vec<String>,
235
236    /// Tools the agent is explicitly denied from using.
237    #[serde(default, alias = "denyTools")]
238    pub deny: Vec<String>,
239
240    /// Named services the agent may access (e.g. "memory", "cron").
241    #[serde(default, alias = "serviceAccess")]
242    pub service_access: Vec<String>,
243}
244
245/// Resource type for capability limit checks.
246///
247/// Each variant carries the current value to check against limits.
248#[non_exhaustive]
249#[derive(Debug, Clone)]
250pub enum ResourceType {
251    /// Memory usage in bytes.
252    Memory(u64),
253    /// CPU time in milliseconds.
254    CpuTime(u64),
255    /// Number of concurrent tool calls.
256    ConcurrentTools(u32),
257    /// Number of IPC messages sent.
258    Messages(u64),
259}
260
261/// Capability checker that enforces per-agent access control.
262///
263/// The checker reads capabilities from the process table and
264/// validates tool access, IPC routing, service access, and
265/// resource limits. It is designed to be called from tool
266/// execution hooks without requiring a direct dependency on
267/// the kernel crate (via trait objects in the core).
268pub struct CapabilityChecker {
269    process_table: Arc<ProcessTable>,
270}
271
272impl CapabilityChecker {
273    /// Create a new capability checker backed by the given process table.
274    pub fn new(process_table: Arc<ProcessTable>) -> Self {
275        Self { process_table }
276    }
277
278    /// Check whether a process is allowed to call a tool.
279    ///
280    /// Evaluation order:
281    /// 1. Agent must have `can_exec_tools` enabled.
282    /// 2. If `tool_permissions.deny` is non-empty, the tool must not
283    ///    be in the deny list (deny overrides allow).
284    /// 3. If `tool_permissions.allow` is non-empty, the tool must be
285    ///    in the allow list.
286    /// 4. Shell tools require `sandbox.allow_shell`.
287    ///
288    /// # Errors
289    ///
290    /// Returns `KernelError::CapabilityDenied` with a description
291    /// of why access was denied.
292    pub fn check_tool_access(
293        &self,
294        pid: Pid,
295        tool_name: &str,
296        tool_permissions: Option<&ToolPermissions>,
297        sandbox: Option<&SandboxPolicy>,
298    ) -> Result<(), KernelError> {
299        let entry = self
300            .process_table
301            .get(pid)
302            .ok_or(KernelError::ProcessNotFound { pid })?;
303
304        // Must have tool execution capability
305        if !entry.capabilities.can_exec_tools {
306            return Err(KernelError::CapabilityDenied {
307                pid,
308                action: format!("execute tool '{tool_name}'"),
309                reason: "agent does not have can_exec_tools capability".into(),
310            });
311        }
312
313        // Check deny list (deny overrides allow)
314        if let Some(perms) = tool_permissions {
315            if perms.deny.iter().any(|d| d == tool_name) {
316                return Err(KernelError::CapabilityDenied {
317                    pid,
318                    action: format!("execute tool '{tool_name}'"),
319                    reason: "tool is in the deny list".into(),
320                });
321            }
322
323            // Check allow list (empty = all allowed)
324            if !perms.allow.is_empty() && !perms.allow.iter().any(|a| a == tool_name) {
325                return Err(KernelError::CapabilityDenied {
326                    pid,
327                    action: format!("execute tool '{tool_name}'"),
328                    reason: "tool is not in the allow list".into(),
329                });
330            }
331        }
332
333        // Check sandbox policy for shell tools
334        if let Some(sb) = sandbox
335            && is_shell_tool(tool_name)
336            && !sb.allow_shell
337        {
338            return Err(KernelError::CapabilityDenied {
339                pid,
340                action: format!("execute shell tool '{tool_name}'"),
341                reason: "sandbox policy does not allow shell execution".into(),
342            });
343        }
344
345        Ok(())
346    }
347
348    /// Check whether a process may send a message to another process.
349    ///
350    /// Uses the sender's IPC scope to determine if communication
351    /// with the target PID is allowed.
352    ///
353    /// # Errors
354    ///
355    /// Returns `KernelError::CapabilityDenied` if IPC is disabled
356    /// or the target is outside the sender's IPC scope.
357    pub fn check_ipc_target(&self, from_pid: Pid, to_pid: Pid) -> Result<(), KernelError> {
358        let entry = self
359            .process_table
360            .get(from_pid)
361            .ok_or(KernelError::ProcessNotFound { pid: from_pid })?;
362
363        if !entry.capabilities.can_ipc {
364            return Err(KernelError::CapabilityDenied {
365                pid: from_pid,
366                action: format!("send IPC message to PID {to_pid}"),
367                reason: "agent does not have IPC capability".into(),
368            });
369        }
370
371        if !entry.capabilities.can_message(to_pid) {
372            return Err(KernelError::CapabilityDenied {
373                pid: from_pid,
374                action: format!("send IPC message to PID {to_pid}"),
375                reason: format!(
376                    "target PID {to_pid} is outside IPC scope {:?}",
377                    entry.capabilities.ipc_scope
378                ),
379            });
380        }
381
382        Ok(())
383    }
384
385    /// Check whether a process may publish or subscribe to a topic.
386    ///
387    /// # Errors
388    ///
389    /// Returns `KernelError::CapabilityDenied` if the agent's IPC
390    /// scope does not permit the given topic.
391    pub fn check_ipc_topic(&self, pid: Pid, topic: &str) -> Result<(), KernelError> {
392        let entry = self
393            .process_table
394            .get(pid)
395            .ok_or(KernelError::ProcessNotFound { pid })?;
396
397        if !entry.capabilities.can_topic(topic) {
398            return Err(KernelError::CapabilityDenied {
399                pid,
400                action: format!("access topic '{topic}'"),
401                reason: format!(
402                    "topic '{topic}' is outside IPC scope {:?}",
403                    entry.capabilities.ipc_scope
404                ),
405            });
406        }
407
408        Ok(())
409    }
410
411    /// Check whether a process may access a named service.
412    ///
413    /// If `tool_permissions` has a non-empty `service_access` list,
414    /// the service name must appear in it.
415    ///
416    /// # Errors
417    ///
418    /// Returns `KernelError::CapabilityDenied` if the service is not
419    /// in the agent's service access list.
420    pub fn check_service_access(
421        &self,
422        pid: Pid,
423        service_name: &str,
424        tool_permissions: Option<&ToolPermissions>,
425    ) -> Result<(), KernelError> {
426        // Verify the PID exists
427        let _entry = self
428            .process_table
429            .get(pid)
430            .ok_or(KernelError::ProcessNotFound { pid })?;
431
432        if let Some(perms) = tool_permissions
433            && !perms.service_access.is_empty()
434            && !perms.service_access.iter().any(|s| s == service_name)
435        {
436            return Err(KernelError::CapabilityDenied {
437                pid,
438                action: format!("access service '{service_name}'"),
439                reason: "service is not in the agent's service access list".into(),
440            });
441        }
442
443        Ok(())
444    }
445
446    /// Check whether a resource usage is within the agent's limits.
447    ///
448    /// # Errors
449    ///
450    /// Returns `KernelError::ResourceLimitExceeded` if the resource
451    /// usage exceeds the agent's configured limits.
452    pub fn check_resource_limit(
453        &self,
454        pid: Pid,
455        resource: &ResourceType,
456    ) -> Result<(), KernelError> {
457        let entry = self
458            .process_table
459            .get(pid)
460            .ok_or(KernelError::ProcessNotFound { pid })?;
461
462        let limits = &entry.capabilities.resource_limits;
463
464        match resource {
465            ResourceType::Memory(bytes) => {
466                if *bytes > limits.max_memory_bytes {
467                    return Err(KernelError::ResourceLimitExceeded {
468                        pid,
469                        resource: "memory".into(),
470                        current: *bytes,
471                        limit: limits.max_memory_bytes,
472                    });
473                }
474            }
475            ResourceType::CpuTime(ms) => {
476                if *ms > limits.max_cpu_time_ms {
477                    return Err(KernelError::ResourceLimitExceeded {
478                        pid,
479                        resource: "cpu_time".into(),
480                        current: *ms,
481                        limit: limits.max_cpu_time_ms,
482                    });
483                }
484            }
485            ResourceType::ConcurrentTools(count) => {
486                if u64::from(*count) > limits.max_tool_calls {
487                    return Err(KernelError::ResourceLimitExceeded {
488                        pid,
489                        resource: "concurrent_tools".into(),
490                        current: u64::from(*count),
491                        limit: limits.max_tool_calls,
492                    });
493                }
494            }
495            ResourceType::Messages(count) => {
496                if *count > limits.max_messages {
497                    return Err(KernelError::ResourceLimitExceeded {
498                        pid,
499                        resource: "messages".into(),
500                        current: *count,
501                        limit: limits.max_messages,
502                    });
503                }
504            }
505        }
506
507        Ok(())
508    }
509
510    /// Get a reference to the underlying process table.
511    pub fn process_table(&self) -> &Arc<ProcessTable> {
512        &self.process_table
513    }
514}
515
516// ── Browser capability elevation via governance gate ────────────────
517
518/// Request to elevate a browser agent's capabilities.
519///
520/// Browser agents start with a restricted sandbox. Elevation requires
521/// governance gate approval before additional permissions are granted.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct CapabilityElevationRequest {
524    /// PID of the agent requesting elevation.
525    pub pid: u64,
526    /// Current capabilities.
527    pub current: AgentCapabilities,
528    /// Requested elevated capabilities.
529    pub requested: AgentCapabilities,
530    /// Justification for elevation.
531    pub reason: String,
532}
533
534/// Result of a capability elevation request.
535#[non_exhaustive]
536#[derive(Debug, Clone)]
537pub enum ElevationResult {
538    /// Elevation granted.
539    Granted {
540        new_capabilities: AgentCapabilities,
541    },
542    /// Elevation denied by governance gate.
543    Denied {
544        reason: String,
545    },
546}
547
548impl AgentCapabilities {
549    /// Build an elevation request from current to requested capabilities.
550    /// The `pid` field is set to 0 and must be filled by the caller.
551    pub fn request_elevation(
552        current: &AgentCapabilities,
553        requested: &AgentCapabilities,
554        platform: &str,
555    ) -> CapabilityElevationRequest {
556        CapabilityElevationRequest {
557            pid: 0, // filled by caller
558            current: current.clone(),
559            requested: requested.clone(),
560            reason: format!("capability elevation for {platform} agent"),
561        }
562    }
563
564    /// Check if elevation is needed (browser agents start restricted).
565    ///
566    /// Returns `true` if the platform is `"browser"` and the requested
567    /// capabilities exceed the browser sandbox defaults (spawn, network,
568    /// or non-restricted IPC scope).
569    pub fn needs_elevation(platform: &str, requested: &AgentCapabilities) -> bool {
570        platform == "browser"
571            && (requested.can_spawn
572                || requested.can_network
573                || !matches!(requested.ipc_scope, IpcScope::Restricted(_)))
574    }
575}
576
577/// Check whether a tool name is a shell execution tool.
578fn is_shell_tool(tool_name: &str) -> bool {
579    matches!(
580        tool_name,
581        "shell_exec" | "exec_shell" | "bash" | "command" | "run_command"
582    )
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn default_capabilities() {
591        let caps = AgentCapabilities::default();
592        assert!(caps.can_spawn);
593        assert!(caps.can_ipc);
594        assert!(caps.can_exec_tools);
595        assert!(!caps.can_network);
596        assert_eq!(caps.ipc_scope, IpcScope::All);
597    }
598
599    #[test]
600    fn default_resource_limits() {
601        let limits = ResourceLimits::default();
602        assert_eq!(limits.max_memory_bytes, 256 * 1024 * 1024);
603        assert_eq!(limits.max_cpu_time_ms, 300_000);
604        assert_eq!(limits.max_tool_calls, 1000);
605        assert_eq!(limits.max_messages, 5000);
606    }
607
608    #[test]
609    fn can_message_all_scope() {
610        let caps = AgentCapabilities::default();
611        assert!(caps.can_message(1));
612        assert!(caps.can_message(999));
613    }
614
615    #[test]
616    fn can_message_restricted_scope() {
617        let caps = AgentCapabilities {
618            ipc_scope: IpcScope::Restricted(vec![1, 2, 3]),
619            ..Default::default()
620        };
621        assert!(caps.can_message(1));
622        assert!(caps.can_message(2));
623        assert!(!caps.can_message(4));
624    }
625
626    #[test]
627    fn can_message_none_scope() {
628        let caps = AgentCapabilities {
629            ipc_scope: IpcScope::None,
630            ..Default::default()
631        };
632        assert!(!caps.can_message(1));
633    }
634
635    #[test]
636    fn can_message_topic_scope_blocks_direct() {
637        let caps = AgentCapabilities {
638            ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
639            ..Default::default()
640        };
641        // Topic-scoped agents cannot direct-message PIDs
642        assert!(!caps.can_message(1));
643        assert!(!caps.can_message(999));
644    }
645
646    #[test]
647    fn can_topic_with_topic_scope() {
648        let caps = AgentCapabilities {
649            ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
650            ..Default::default()
651        };
652        assert!(caps.can_topic("build"));
653        assert!(caps.can_topic("deploy"));
654        assert!(!caps.can_topic("admin"));
655    }
656
657    #[test]
658    fn can_topic_with_all_scope() {
659        let caps = AgentCapabilities::default(); // IpcScope::All
660        assert!(caps.can_topic("anything"));
661    }
662
663    #[test]
664    fn can_topic_with_none_scope() {
665        let caps = AgentCapabilities {
666            ipc_scope: IpcScope::None,
667            ..Default::default()
668        };
669        assert!(!caps.can_topic("build"));
670    }
671
672    #[test]
673    fn can_message_ipc_disabled() {
674        let caps = AgentCapabilities {
675            can_ipc: false,
676            ..Default::default()
677        };
678        assert!(!caps.can_message(1));
679    }
680
681    #[test]
682    fn within_limits_ok() {
683        let caps = AgentCapabilities::default();
684        assert!(caps.within_limits(1000, 1000, 10, 10));
685    }
686
687    #[test]
688    fn within_limits_exceeded() {
689        let caps = AgentCapabilities {
690            resource_limits: ResourceLimits {
691                max_memory_bytes: 100,
692                max_cpu_time_ms: 100,
693                max_tool_calls: 5,
694                max_messages: 5,
695                ..Default::default()
696            },
697            ..Default::default()
698        };
699        assert!(!caps.within_limits(200, 50, 3, 3)); // memory exceeded
700        assert!(!caps.within_limits(50, 200, 3, 3)); // cpu exceeded
701        assert!(!caps.within_limits(50, 50, 10, 3)); // tools exceeded
702        assert!(!caps.within_limits(50, 50, 3, 10)); // messages exceeded
703    }
704
705    #[test]
706    fn serde_roundtrip_capabilities() {
707        let caps = AgentCapabilities {
708            can_spawn: false,
709            can_ipc: true,
710            can_exec_tools: false,
711            can_network: true,
712            ipc_scope: IpcScope::Restricted(vec![1, 2]),
713            resource_limits: ResourceLimits {
714                max_memory_bytes: 1024,
715                max_cpu_time_ms: 500,
716                max_tool_calls: 10,
717                max_messages: 20,
718                ..Default::default()
719            },
720        };
721        let json = serde_json::to_string(&caps).unwrap();
722        let restored: AgentCapabilities = serde_json::from_str(&json).unwrap();
723        assert_eq!(restored, caps);
724    }
725
726    #[test]
727    fn deserialize_empty_capabilities() {
728        let caps: AgentCapabilities = serde_json::from_str("{}").unwrap();
729        assert!(caps.can_spawn);
730        assert!(caps.can_ipc);
731        assert!(caps.can_exec_tools);
732        assert!(!caps.can_network);
733    }
734
735    // ── SandboxPolicy tests ──────────────────────────────────────────
736
737    #[test]
738    fn sandbox_policy_default() {
739        let sb = SandboxPolicy::default();
740        assert!(!sb.allow_shell);
741        assert!(!sb.allow_network);
742        assert!(sb.allowed_paths.is_empty());
743        assert!(sb.denied_paths.is_empty());
744    }
745
746    #[test]
747    fn sandbox_policy_serde_roundtrip() {
748        let sb = SandboxPolicy {
749            allow_shell: true,
750            allow_network: false,
751            allowed_paths: vec!["/workspace".into()],
752            denied_paths: vec!["/etc".into(), "/root".into()],
753        };
754        let json = serde_json::to_string(&sb).unwrap();
755        let restored: SandboxPolicy = serde_json::from_str(&json).unwrap();
756        assert_eq!(restored, sb);
757    }
758
759    // ── ToolPermissions tests ────────────────────────────────────────
760
761    #[test]
762    fn tool_permissions_default() {
763        let perms = ToolPermissions::default();
764        assert!(perms.allow.is_empty());
765        assert!(perms.deny.is_empty());
766        assert!(perms.service_access.is_empty());
767    }
768
769    #[test]
770    fn tool_permissions_serde_roundtrip() {
771        let perms = ToolPermissions {
772            allow: vec!["read_file".into(), "write_file".into()],
773            deny: vec!["shell_exec".into()],
774            service_access: vec!["memory".into()],
775        };
776        let json = serde_json::to_string(&perms).unwrap();
777        let restored: ToolPermissions = serde_json::from_str(&json).unwrap();
778        assert_eq!(restored, perms);
779    }
780
781    // ── CapabilityChecker tests ──────────────────────────────────────
782
783    use crate::process::{ProcessEntry, ProcessState, ResourceUsage};
784    use tokio_util::sync::CancellationToken;
785
786    fn make_checker_with_entry(caps: AgentCapabilities) -> (CapabilityChecker, Pid) {
787        let table = Arc::new(ProcessTable::new(16));
788        let entry = ProcessEntry {
789            pid: 0,
790            agent_id: "test-agent".to_owned(),
791            state: ProcessState::Running,
792            capabilities: caps,
793            resource_usage: ResourceUsage::default(),
794            cancel_token: CancellationToken::new(),
795            parent_pid: None,
796        };
797        let pid = table.insert(entry).unwrap();
798        (CapabilityChecker::new(table), pid)
799    }
800
801    #[test]
802    fn checker_tool_access_allowed_by_default() {
803        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
804        assert!(
805            checker
806                .check_tool_access(pid, "read_file", None, None)
807                .is_ok()
808        );
809    }
810
811    #[test]
812    fn checker_tool_access_denied_no_exec_tools() {
813        let caps = AgentCapabilities {
814            can_exec_tools: false,
815            ..Default::default()
816        };
817        let (checker, pid) = make_checker_with_entry(caps);
818        let result = checker.check_tool_access(pid, "read_file", None, None);
819        assert!(result.is_err());
820    }
821
822    #[test]
823    fn checker_tool_deny_list_blocks() {
824        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
825        let perms = ToolPermissions {
826            deny: vec!["shell_exec".into()],
827            ..Default::default()
828        };
829        let result = checker.check_tool_access(pid, "shell_exec", Some(&perms), None);
830        assert!(result.is_err());
831    }
832
833    #[test]
834    fn checker_tool_deny_overrides_allow() {
835        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
836        let perms = ToolPermissions {
837            allow: vec!["shell_exec".into()],
838            deny: vec!["shell_exec".into()],
839            ..Default::default()
840        };
841        let result = checker.check_tool_access(pid, "shell_exec", Some(&perms), None);
842        assert!(result.is_err());
843    }
844
845    #[test]
846    fn checker_tool_allow_list_restricts() {
847        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
848        let perms = ToolPermissions {
849            allow: vec!["read_file".into(), "write_file".into()],
850            ..Default::default()
851        };
852        assert!(
853            checker
854                .check_tool_access(pid, "read_file", Some(&perms), None)
855                .is_ok()
856        );
857        assert!(
858            checker
859                .check_tool_access(pid, "web_search", Some(&perms), None)
860                .is_err()
861        );
862    }
863
864    #[test]
865    fn checker_sandbox_blocks_shell() {
866        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
867        let sb = SandboxPolicy {
868            allow_shell: false,
869            ..Default::default()
870        };
871        let result = checker.check_tool_access(pid, "shell_exec", None, Some(&sb));
872        assert!(result.is_err());
873    }
874
875    #[test]
876    fn checker_sandbox_allows_shell() {
877        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
878        let sb = SandboxPolicy {
879            allow_shell: true,
880            ..Default::default()
881        };
882        assert!(
883            checker
884                .check_tool_access(pid, "shell_exec", None, Some(&sb))
885                .is_ok()
886        );
887    }
888
889    #[test]
890    fn checker_ipc_allowed() {
891        let table = Arc::new(ProcessTable::new(16));
892        let entry1 = ProcessEntry {
893            pid: 0,
894            agent_id: "sender".to_owned(),
895            state: ProcessState::Running,
896            capabilities: AgentCapabilities::default(), // IpcScope::All
897            resource_usage: ResourceUsage::default(),
898            cancel_token: CancellationToken::new(),
899            parent_pid: None,
900        };
901        let entry2 = ProcessEntry {
902            pid: 0,
903            agent_id: "receiver".to_owned(),
904            state: ProcessState::Running,
905            capabilities: AgentCapabilities::default(),
906            resource_usage: ResourceUsage::default(),
907            cancel_token: CancellationToken::new(),
908            parent_pid: None,
909        };
910        let pid1 = table.insert(entry1).unwrap();
911        let pid2 = table.insert(entry2).unwrap();
912
913        let checker = CapabilityChecker::new(table);
914        assert!(checker.check_ipc_target(pid1, pid2).is_ok());
915    }
916
917    #[test]
918    fn checker_ipc_denied_no_ipc() {
919        let caps = AgentCapabilities {
920            can_ipc: false,
921            ..Default::default()
922        };
923        let (checker, pid) = make_checker_with_entry(caps);
924        let result = checker.check_ipc_target(pid, 999);
925        assert!(result.is_err());
926    }
927
928    #[test]
929    fn checker_ipc_denied_restricted_scope() {
930        let caps = AgentCapabilities {
931            ipc_scope: IpcScope::Restricted(vec![5, 10]),
932            ..Default::default()
933        };
934        let (checker, pid) = make_checker_with_entry(caps);
935        assert!(checker.check_ipc_target(pid, 5).is_ok());
936        assert!(checker.check_ipc_target(pid, 10).is_ok());
937        assert!(checker.check_ipc_target(pid, 15).is_err());
938    }
939
940    #[test]
941    fn checker_service_access_allowed_empty_list() {
942        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
943        // Empty service_access = all services allowed
944        let perms = ToolPermissions::default();
945        assert!(
946            checker
947                .check_service_access(pid, "memory", Some(&perms))
948                .is_ok()
949        );
950    }
951
952    #[test]
953    fn checker_service_access_restricted() {
954        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
955        let perms = ToolPermissions {
956            service_access: vec!["memory".into(), "cron".into()],
957            ..Default::default()
958        };
959        assert!(
960            checker
961                .check_service_access(pid, "memory", Some(&perms))
962                .is_ok()
963        );
964        assert!(
965            checker
966                .check_service_access(pid, "cron", Some(&perms))
967                .is_ok()
968        );
969        assert!(
970            checker
971                .check_service_access(pid, "network", Some(&perms))
972                .is_err()
973        );
974    }
975
976    #[test]
977    fn checker_resource_limit_memory_ok() {
978        let (checker, pid) = make_checker_with_entry(AgentCapabilities::default());
979        assert!(
980            checker
981                .check_resource_limit(pid, &ResourceType::Memory(1024))
982                .is_ok()
983        );
984    }
985
986    #[test]
987    fn checker_resource_limit_memory_exceeded() {
988        let caps = AgentCapabilities {
989            resource_limits: ResourceLimits {
990                max_memory_bytes: 100,
991                ..Default::default()
992            },
993            ..Default::default()
994        };
995        let (checker, pid) = make_checker_with_entry(caps);
996        let result = checker.check_resource_limit(pid, &ResourceType::Memory(200));
997        assert!(result.is_err());
998    }
999
1000    #[test]
1001    fn checker_resource_limit_cpu_exceeded() {
1002        let caps = AgentCapabilities {
1003            resource_limits: ResourceLimits {
1004                max_cpu_time_ms: 100,
1005                ..Default::default()
1006            },
1007            ..Default::default()
1008        };
1009        let (checker, pid) = make_checker_with_entry(caps);
1010        let result = checker.check_resource_limit(pid, &ResourceType::CpuTime(200));
1011        assert!(result.is_err());
1012    }
1013
1014    #[test]
1015    fn checker_resource_limit_messages_exceeded() {
1016        let caps = AgentCapabilities {
1017            resource_limits: ResourceLimits {
1018                max_messages: 10,
1019                ..Default::default()
1020            },
1021            ..Default::default()
1022        };
1023        let (checker, pid) = make_checker_with_entry(caps);
1024        let result = checker.check_resource_limit(pid, &ResourceType::Messages(20));
1025        assert!(result.is_err());
1026    }
1027
1028    #[test]
1029    fn checker_ipc_topic_allowed() {
1030        let caps = AgentCapabilities {
1031            ipc_scope: IpcScope::Topic(vec!["build".into(), "deploy".into()]),
1032            ..Default::default()
1033        };
1034        let (checker, pid) = make_checker_with_entry(caps);
1035        assert!(checker.check_ipc_topic(pid, "build").is_ok());
1036        assert!(checker.check_ipc_topic(pid, "deploy").is_ok());
1037        assert!(checker.check_ipc_topic(pid, "admin").is_err());
1038    }
1039
1040    #[test]
1041    fn checker_ipc_topic_denied_for_direct_messaging() {
1042        let caps = AgentCapabilities {
1043            ipc_scope: IpcScope::Topic(vec!["build".into()]),
1044            ..Default::default()
1045        };
1046        let (checker, pid) = make_checker_with_entry(caps);
1047        // Topic-scoped agents cannot direct-message PIDs
1048        assert!(checker.check_ipc_target(pid, 999).is_err());
1049    }
1050
1051    #[test]
1052    fn checker_nonexistent_pid() {
1053        let table = Arc::new(ProcessTable::new(16));
1054        let checker = CapabilityChecker::new(table);
1055        assert!(
1056            checker
1057                .check_tool_access(999, "read_file", None, None)
1058                .is_err()
1059        );
1060        assert!(checker.check_ipc_target(999, 1).is_err());
1061        assert!(
1062            checker
1063                .check_resource_limit(999, &ResourceType::Memory(0))
1064                .is_err()
1065        );
1066    }
1067
1068    #[test]
1069    fn browser_default_uses_restricted_ipc() {
1070        let caps = AgentCapabilities::browser_default();
1071        assert!(
1072            matches!(caps.ipc_scope, IpcScope::Restricted(ref pids) if pids.is_empty()),
1073            "browser agents must default to IpcScope::Restricted([])"
1074        );
1075        assert!(!caps.can_spawn, "browser agents must not spawn");
1076        assert!(!caps.can_network, "browser agents must not access network");
1077        assert!(caps.can_ipc, "browser agents need IPC for kernel comms");
1078        assert!(caps.can_exec_tools, "browser agents need tool execution");
1079        // Tighter resource limits than default
1080        assert!(caps.resource_limits.max_memory_bytes < ResourceLimits::default().max_memory_bytes);
1081        assert!(caps.resource_limits.max_cpu_time_ms < ResourceLimits::default().max_cpu_time_ms);
1082    }
1083
1084    #[test]
1085    fn browser_default_blocks_direct_messages() {
1086        let caps = AgentCapabilities::browser_default();
1087        // Empty restricted list means no PIDs are reachable
1088        assert!(!caps.can_message(1));
1089        assert!(!caps.can_message(999));
1090    }
1091
1092    #[test]
1093    fn is_shell_tool_recognizes_variants() {
1094        assert!(is_shell_tool("shell_exec"));
1095        assert!(is_shell_tool("exec_shell"));
1096        assert!(is_shell_tool("bash"));
1097        assert!(is_shell_tool("command"));
1098        assert!(is_shell_tool("run_command"));
1099        assert!(!is_shell_tool("read_file"));
1100        assert!(!is_shell_tool("web_search"));
1101    }
1102
1103    // ── Browser elevation tests ────────────────────────────────────
1104
1105    #[test]
1106    fn browser_elevation_needed_for_spawn() {
1107        let requested = AgentCapabilities {
1108            can_spawn: true,
1109            ..AgentCapabilities::browser_default()
1110        };
1111        assert!(AgentCapabilities::needs_elevation("browser", &requested));
1112    }
1113
1114    #[test]
1115    fn browser_elevation_needed_for_network() {
1116        let requested = AgentCapabilities {
1117            can_network: true,
1118            ..AgentCapabilities::browser_default()
1119        };
1120        assert!(AgentCapabilities::needs_elevation("browser", &requested));
1121    }
1122
1123    #[test]
1124    fn browser_elevation_not_needed_for_restricted() {
1125        // Browser defaults are already restricted -- no elevation needed.
1126        let requested = AgentCapabilities::browser_default();
1127        assert!(!AgentCapabilities::needs_elevation("browser", &requested));
1128    }
1129
1130    #[test]
1131    fn non_browser_elevation_not_needed() {
1132        // Even with elevated caps, non-browser platform never needs elevation.
1133        let requested = AgentCapabilities {
1134            can_spawn: true,
1135            can_network: true,
1136            ipc_scope: IpcScope::All,
1137            ..Default::default()
1138        };
1139        assert!(!AgentCapabilities::needs_elevation("native", &requested));
1140        assert!(!AgentCapabilities::needs_elevation("wasi", &requested));
1141    }
1142
1143    #[test]
1144    fn browser_elevation_needed_for_ipc_all() {
1145        let requested = AgentCapabilities {
1146            ipc_scope: IpcScope::All,
1147            ..AgentCapabilities::browser_default()
1148        };
1149        assert!(AgentCapabilities::needs_elevation("browser", &requested));
1150    }
1151
1152    #[test]
1153    fn request_elevation_builds_request() {
1154        let current = AgentCapabilities::browser_default();
1155        let requested = AgentCapabilities {
1156            can_network: true,
1157            ..AgentCapabilities::browser_default()
1158        };
1159        let req = AgentCapabilities::request_elevation(&current, &requested, "browser");
1160        assert_eq!(req.pid, 0);
1161        assert!(!req.current.can_network);
1162        assert!(req.requested.can_network);
1163        assert!(req.reason.contains("browser"));
1164    }
1165
1166    // ── K1-G4: Disk quota tests (os-patterns) ────────────────────
1167
1168    #[cfg(feature = "os-patterns")]
1169    mod disk_quota_tests {
1170        use super::*;
1171
1172        #[test]
1173        fn default_disk_quota_is_100_mib() {
1174            let limits = ResourceLimits::default();
1175            assert_eq!(limits.max_disk_bytes, 100 * 1024 * 1024);
1176        }
1177
1178        #[test]
1179        fn browser_disk_quota_is_10_mib() {
1180            let caps = AgentCapabilities::browser_default();
1181            assert_eq!(caps.resource_limits.max_disk_bytes, 10 * 1024 * 1024);
1182        }
1183
1184        #[test]
1185        fn disk_quota_serde_roundtrip() {
1186            let limits = ResourceLimits {
1187                max_disk_bytes: 50 * 1024 * 1024,
1188                ..Default::default()
1189            };
1190            let json = serde_json::to_string(&limits).unwrap();
1191            let restored: ResourceLimits = serde_json::from_str(&json).unwrap();
1192            assert_eq!(restored.max_disk_bytes, 50 * 1024 * 1024);
1193        }
1194
1195        #[test]
1196        fn disk_quota_zero_means_unlimited() {
1197            let limits = ResourceLimits {
1198                max_disk_bytes: 0,
1199                ..Default::default()
1200            };
1201            assert_eq!(limits.max_disk_bytes, 0);
1202        }
1203    }
1204}