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/// Topics browser / [`IpcScope::Restricted`] agents may access without elevation.
80///
81/// A topic is public when it is exactly `"public"` or has the `public.` prefix
82/// (e.g. `public.events`, `public.health`). All other topics require a broader
83/// IPC scope (typically granted via governance-gated capability elevation).
84pub fn is_public_topic(topic: &str) -> bool {
85    topic == "public" || topic.starts_with("public.")
86}
87
88/// IPC scope defining which message targets an agent may communicate with.
89#[non_exhaustive]
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub enum IpcScope {
92    /// Agent may communicate with all other agents.
93    #[default]
94    All,
95    /// Agent may only communicate with its parent.
96    ParentOnly,
97    /// Agent may communicate with a specified set of PIDs.
98    ///
99    /// Topic pub/sub is limited to [public topics](is_public_topic) only
100    /// (S7 / WEFT-108 browser sandbox default).
101    Restricted(Vec<u64>),
102    /// Agent may only publish/subscribe to specified topics (no direct PID messaging).
103    Topic(Vec<String>),
104    /// Agent may not send IPC messages.
105    None,
106}
107
108/// Capabilities assigned to an agent process.
109///
110/// Governs what the agent is allowed to do within the kernel.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct AgentCapabilities {
113    /// Whether the agent can spawn child processes.
114    #[serde(default = "default_true", alias = "canSpawn")]
115    pub can_spawn: bool,
116
117    /// Whether the agent can send/receive IPC messages.
118    #[serde(default = "default_true", alias = "canIpc")]
119    pub can_ipc: bool,
120
121    /// Whether the agent can execute tools.
122    #[serde(default = "default_true", alias = "canExecTools")]
123    pub can_exec_tools: bool,
124
125    /// Whether the agent can make network requests.
126    #[serde(default, alias = "canNetwork")]
127    pub can_network: bool,
128
129    /// IPC scope restriction.
130    #[serde(default, alias = "ipcScope")]
131    pub ipc_scope: IpcScope,
132
133    /// Resource limits for this agent.
134    #[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    /// Create capabilities for a browser-platform agent.
157    ///
158    /// Browser agents default to restricted IPC scope (empty allow-list),
159    /// no spawning, no network access, and no shell — maximising the
160    /// sandbox surface for untrusted in-browser code.
161    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, // 64 MiB
170                max_cpu_time_ms: 60_000,            // 1 minute
171                max_tool_calls: 200,
172                max_messages: 500,
173                #[cfg(feature = "os-patterns")]
174                max_disk_bytes: 10 * 1024 * 1024, // 10 MiB for browser agents
175            },
176        }
177    }
178
179    /// Check whether the agent is allowed to send a message to the given PID.
180    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, // Caller must check parent separately
187            IpcScope::Restricted(pids) => pids.contains(&target_pid),
188            IpcScope::Topic(_) => false, // Topic-scoped agents cannot direct-message PIDs
189            IpcScope::None => false,
190        }
191    }
192
193    /// Check whether the agent is allowed to publish/subscribe to a topic.
194    ///
195    /// [`IpcScope::Restricted`] (browser default) only permits
196    /// [public topics](is_public_topic). Other scopes are unchanged.
197    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, // topic access not restricted for parent-only
205            IpcScope::Restricted(_) => is_public_topic(topic),
206            IpcScope::None => false,
207        }
208    }
209
210    /// Check whether the resource usage is within limits.
211    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/// Sandbox policy governing filesystem and shell access.
220///
221/// Controls whether an agent can execute shell commands, access
222/// the network, and which filesystem paths are allowed or denied.
223#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
224pub struct SandboxPolicy {
225    /// Whether the agent may execute shell commands.
226    #[serde(default, alias = "allowShell")]
227    pub allow_shell: bool,
228
229    /// Whether the agent may make network requests.
230    #[serde(default, alias = "allowNetwork")]
231    pub allow_network: bool,
232
233    /// Filesystem paths the agent is allowed to access.
234    #[serde(default, alias = "allowedPaths")]
235    pub allowed_paths: Vec<String>,
236
237    /// Filesystem paths the agent is explicitly denied from accessing.
238    #[serde(default, alias = "deniedPaths")]
239    pub denied_paths: Vec<String>,
240}
241
242/// Tool-level permission configuration.
243///
244/// An allow/deny list model where deny overrides allow.
245/// Empty `allow` means all tools permitted (unless explicitly denied).
246#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
247pub struct ToolPermissions {
248    /// Tools the agent is allowed to use (empty = all allowed).
249    #[serde(default, alias = "tools")]
250    pub allow: Vec<String>,
251
252    /// Tools the agent is explicitly denied from using.
253    #[serde(default, alias = "denyTools")]
254    pub deny: Vec<String>,
255
256    /// Named services the agent may access (e.g. "memory", "cron").
257    #[serde(default, alias = "serviceAccess")]
258    pub service_access: Vec<String>,
259}
260
261/// Resource type for capability limit checks.
262///
263/// Each variant carries the current value to check against limits.
264#[non_exhaustive]
265#[derive(Debug, Clone)]
266pub enum ResourceType {
267    /// Memory usage in bytes.
268    Memory(u64),
269    /// CPU time in milliseconds.
270    CpuTime(u64),
271    /// Number of concurrent tool calls.
272    ConcurrentTools(u32),
273    /// Number of IPC messages sent.
274    Messages(u64),
275}
276
277/// Capability checker that enforces per-agent access control.
278///
279/// The checker reads capabilities from the process table and
280/// validates tool access, IPC routing, service access, and
281/// resource limits. It is designed to be called from tool
282/// execution hooks without requiring a direct dependency on
283/// the kernel crate (via trait objects in the core).
284pub struct CapabilityChecker {
285    process_table: Arc<ProcessTable>,
286}
287
288impl CapabilityChecker {
289    /// Create a new capability checker backed by the given process table.
290    pub fn new(process_table: Arc<ProcessTable>) -> Self {
291        Self { process_table }
292    }
293
294    /// Check whether a process is allowed to call a tool.
295    ///
296    /// Evaluation order:
297    /// 1. Agent must have `can_exec_tools` enabled.
298    /// 2. If `tool_permissions.deny` is non-empty, the tool must not
299    ///    be in the deny list (deny overrides allow).
300    /// 3. If `tool_permissions.allow` is non-empty, the tool must be
301    ///    in the allow list.
302    /// 4. Shell tools require `sandbox.allow_shell`.
303    ///
304    /// # Errors
305    ///
306    /// Returns `KernelError::CapabilityDenied` with a description
307    /// of why access was denied.
308    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        // Must have tool execution capability
321        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        // Check deny list (deny overrides allow)
330        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            // Check allow list (empty = all allowed)
340            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        // Check sandbox policy for shell tools
350        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    /// Check whether a process may send a message to another process.
365    ///
366    /// Uses the sender's IPC scope to determine if communication
367    /// with the target PID is allowed.
368    ///
369    /// # Errors
370    ///
371    /// Returns `KernelError::CapabilityDenied` if IPC is disabled
372    /// or the target is outside the sender's IPC scope.
373    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    /// Check whether a process may publish or subscribe to a topic.
402    ///
403    /// # Errors
404    ///
405    /// Returns `KernelError::CapabilityDenied` if the agent's IPC
406    /// scope does not permit the given topic.
407    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    /// Check whether a process may access a named service.
428    ///
429    /// If `tool_permissions` has a non-empty `service_access` list,
430    /// the service name must appear in it.
431    ///
432    /// # Errors
433    ///
434    /// Returns `KernelError::CapabilityDenied` if the service is not
435    /// in the agent's service access list.
436    pub fn check_service_access(
437        &self,
438        pid: Pid,
439        service_name: &str,
440        tool_permissions: Option<&ToolPermissions>,
441    ) -> Result<(), KernelError> {
442        // Verify the PID exists
443        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    /// Check whether a resource usage is within the agent's limits.
463    ///
464    /// # Errors
465    ///
466    /// Returns `KernelError::ResourceLimitExceeded` if the resource
467    /// usage exceeds the agent's configured limits.
468    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    /// Get a reference to the underlying process table.
527    pub fn process_table(&self) -> &Arc<ProcessTable> {
528        &self.process_table
529    }
530}
531
532// ── Browser capability elevation via governance gate ────────────────
533
534/// Request to elevate a browser agent's capabilities.
535///
536/// Browser agents start with a restricted sandbox. Elevation requires
537/// governance gate approval before additional permissions are granted.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct CapabilityElevationRequest {
540    /// PID of the agent requesting elevation.
541    pub pid: u64,
542    /// Current capabilities.
543    pub current: AgentCapabilities,
544    /// Requested elevated capabilities.
545    pub requested: AgentCapabilities,
546    /// Justification for elevation.
547    pub reason: String,
548}
549
550/// Result of a capability elevation request.
551#[non_exhaustive]
552#[derive(Debug, Clone)]
553pub enum ElevationResult {
554    /// Elevation granted.
555    Granted { new_capabilities: AgentCapabilities },
556    /// Elevation denied by governance gate.
557    Denied { reason: String },
558}
559
560impl AgentCapabilities {
561    /// Build an elevation request from current to requested capabilities.
562    /// The `pid` field is set to 0 and must be filled by the caller.
563    pub fn request_elevation(
564        current: &AgentCapabilities,
565        requested: &AgentCapabilities,
566        platform: &str,
567    ) -> CapabilityElevationRequest {
568        CapabilityElevationRequest {
569            pid: 0, // filled by caller
570            current: current.clone(),
571            requested: requested.clone(),
572            reason: format!("capability elevation for {platform} agent"),
573        }
574    }
575
576    /// Build an elevation request with governance gating and chain logging.
577    ///
578    /// If a governance gate is provided, the request is checked against
579    /// the gate policy. If denied, returns `ElevationResult::Denied`.
580    /// If a chain manager is provided, the elevation attempt is logged.
581    #[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        // Governance gate: check policy before allowing elevation.
591        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                // Chain logging: record denied elevation.
606                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        // Chain logging: record approved elevation.
625        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    /// Check if elevation is needed (browser agents start restricted).
645    ///
646    /// Returns `true` if the platform is `"browser"` and the requested
647    /// capabilities exceed the browser sandbox defaults (spawn, network,
648    /// or non-restricted IPC scope).
649    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
657/// Check whether a tool name is a shell execution tool.
658fn 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        // Topic-scoped agents cannot direct-message PIDs
722        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(); // IpcScope::All
740        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)); // memory exceeded
779        assert!(!caps.within_limits(50, 200, 3, 3)); // cpu exceeded
780        assert!(!caps.within_limits(50, 50, 10, 3)); // tools exceeded
781        assert!(!caps.within_limits(50, 50, 3, 10)); // messages exceeded
782    }
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    // ── SandboxPolicy tests ──────────────────────────────────────────
814
815    #[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    // ── ToolPermissions tests ────────────────────────────────────────
838
839    #[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    // ── CapabilityChecker tests ──────────────────────────────────────
860
861    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(), // IpcScope::All
975            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        // Empty service_access = all services allowed
1022        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        // Topic-scoped agents cannot direct-message PIDs
1126        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        // Tighter resource limits than default
1158        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        // Empty restricted list means no PIDs are reachable
1166        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    /// WEFT-108 / S7: browser nodes with Restricted IPC cannot subscribe
1182    /// to non-public topics.
1183    #[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        // Public topics are allowed
1192        assert!(caps.can_topic("public"));
1193        assert!(caps.can_topic("public.events"));
1194        assert!(caps.can_topic("public.health"));
1195
1196        // Non-public topics are denied
1197        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        // Enforced via CapabilityChecker as well
1203        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        // Direct PID messaging still uses the allowlist
1218        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    // ── Browser elevation tests ────────────────────────────────────
1234
1235    #[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        // Browser defaults are already restricted -- no elevation needed.
1256        let requested = AgentCapabilities::browser_default();
1257        assert!(!AgentCapabilities::needs_elevation("browser", &requested));
1258    }
1259
1260    #[test]
1261    fn non_browser_elevation_not_needed() {
1262        // Even with elevated caps, non-browser platform never needs elevation.
1263        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(&current, &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    // ── K1-G4: Disk quota tests (os-patterns) ────────────────────
1297
1298    #[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}