Skip to main content

clawft_kernel/
tree_view.rs

1//! Per-agent tree views with capability-based access filtering (K5-G3).
2//!
3//! [`AgentTreeView`] wraps tree-like access and filters paths based on an
4//! agent's capabilities. Agents only see authorized subtrees.
5//!
6//! This is a stand-alone module that does not depend on `TreeManager` directly
7//! (which requires `exochain`). Instead it provides path-level authorization
8//! that can be composed with any tree backend.
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::KernelError;
13use crate::process::Pid;
14
15// ---------------------------------------------------------------------------
16// TreeScope
17// ---------------------------------------------------------------------------
18
19/// Defines what subtrees an agent is allowed to access.
20#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub enum TreeScope {
23    /// Full access to the entire tree.
24    Full,
25    /// Agent sees only its own subtree and read-only kernel config.
26    Restricted {
27        /// The agent's own subtree root.
28        agent_path: String,
29    },
30    /// Agent sees its own subtree and specific namespace(s).
31    Namespace {
32        /// The agent's own subtree root.
33        agent_path: String,
34        /// Additional namespace paths the agent can access.
35        namespaces: Vec<String>,
36    },
37    /// No tree access.
38    None,
39}
40
41// ---------------------------------------------------------------------------
42// AgentTreeView
43// ---------------------------------------------------------------------------
44
45/// A filtered view of the resource tree scoped to an agent's capabilities.
46///
47/// All path operations pass through the view's authorization filter.
48/// Unauthorized access returns [`KernelError::CapabilityDenied`].
49pub struct AgentTreeView {
50    /// Agent identifier.
51    pub agent_id: String,
52    /// Agent's PID.
53    pub pid: Pid,
54    /// Tree scope defining allowed paths.
55    pub scope: TreeScope,
56}
57
58impl AgentTreeView {
59    /// Create a new tree view for an agent.
60    pub fn new(agent_id: String, pid: Pid, scope: TreeScope) -> Self {
61        Self {
62            agent_id,
63            pid,
64            scope,
65        }
66    }
67
68    /// Create a full-access tree view.
69    pub fn full_access(agent_id: String, pid: Pid) -> Self {
70        Self::new(agent_id, pid, TreeScope::Full)
71    }
72
73    /// Create a restricted tree view for an agent.
74    pub fn restricted(agent_id: String, pid: Pid) -> Self {
75        let agent_path = format!("/agents/{agent_id}");
76        Self::new(agent_id, pid, TreeScope::Restricted { agent_path })
77    }
78
79    /// Create a namespace-scoped tree view.
80    pub fn namespace_scoped(agent_id: String, pid: Pid, namespaces: Vec<String>) -> Self {
81        let agent_path = format!("/agents/{agent_id}");
82        Self::new(
83            agent_id,
84            pid,
85            TreeScope::Namespace {
86                agent_path,
87                namespaces,
88            },
89        )
90    }
91
92    /// Check whether a path is authorized for reading.
93    pub fn can_read(&self, path: &str) -> bool {
94        match &self.scope {
95            TreeScope::Full => true,
96            TreeScope::Restricted { agent_path } => {
97                path.starts_with(agent_path) || path.starts_with("/kernel/config/") // read-only config access
98            }
99            TreeScope::Namespace {
100                agent_path,
101                namespaces,
102            } => path.starts_with(agent_path) || namespaces.iter().any(|ns| path.starts_with(ns)),
103            TreeScope::None => false,
104        }
105    }
106
107    /// Check whether a path is authorized for writing.
108    pub fn can_write(&self, path: &str) -> bool {
109        match &self.scope {
110            TreeScope::Full => true,
111            TreeScope::Restricted { agent_path } => {
112                // Restricted agents can only write within their own subtree.
113                path.starts_with(agent_path)
114            }
115            TreeScope::Namespace {
116                agent_path,
117                namespaces,
118            } => path.starts_with(agent_path) || namespaces.iter().any(|ns| path.starts_with(ns)),
119            TreeScope::None => false,
120        }
121    }
122
123    /// Assert read access or return a permission error.
124    pub fn assert_read(&self, path: &str) -> Result<(), KernelError> {
125        if self.can_read(path) {
126            Ok(())
127        } else {
128            Err(KernelError::CapabilityDenied {
129                pid: self.pid,
130                action: format!("read path '{path}'"),
131                reason: format!("agent '{}' not authorized for path '{path}'", self.agent_id),
132            })
133        }
134    }
135
136    /// Assert write access or return a permission error.
137    pub fn assert_write(&self, path: &str) -> Result<(), KernelError> {
138        if self.can_write(path) {
139            Ok(())
140        } else {
141            Err(KernelError::CapabilityDenied {
142                pid: self.pid,
143                action: format!("write path '{path}'"),
144                reason: format!(
145                    "agent '{}' not authorized to write path '{path}'",
146                    self.agent_id
147                ),
148            })
149        }
150    }
151}
152
153// ── Tests ─────────────────────────────────────────────────────────────────
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn pid(n: u64) -> Pid {
160        n
161    }
162
163    #[test]
164    fn full_access_reads_everything() {
165        let view = AgentTreeView::full_access("admin".into(), pid(1));
166        assert!(view.can_read("/kernel/services/health"));
167        assert!(view.can_read("/agents/other"));
168        assert!(view.can_read("/kernel/secrets/api"));
169    }
170
171    #[test]
172    fn full_access_writes_everything() {
173        let view = AgentTreeView::full_access("admin".into(), pid(1));
174        assert!(view.can_write("/kernel/config/app/key"));
175        assert!(view.can_write("/agents/admin/state"));
176    }
177
178    #[test]
179    fn restricted_sees_own_subtree() {
180        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
181        assert!(view.can_read("/agents/worker-1/state"));
182        assert!(view.can_read("/agents/worker-1/logs/entry"));
183    }
184
185    #[test]
186    fn restricted_sees_config_readonly() {
187        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
188        assert!(view.can_read("/kernel/config/app/timeout"));
189        assert!(!view.can_write("/kernel/config/app/timeout"));
190    }
191
192    #[test]
193    fn restricted_cannot_see_other_agents() {
194        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
195        assert!(!view.can_read("/agents/worker-2/state"));
196    }
197
198    #[test]
199    fn restricted_cannot_see_secrets() {
200        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
201        assert!(!view.can_read("/kernel/secrets/api_key"));
202    }
203
204    #[test]
205    fn namespace_scoped_sees_namespaces() {
206        let view = AgentTreeView::namespace_scoped(
207            "ns-agent".into(),
208            pid(3),
209            vec!["/namespaces/prod".into()],
210        );
211        assert!(view.can_read("/agents/ns-agent/state"));
212        assert!(view.can_read("/namespaces/prod/config"));
213        assert!(view.can_write("/namespaces/prod/data"));
214    }
215
216    #[test]
217    fn namespace_scoped_cannot_see_other_namespaces() {
218        let view = AgentTreeView::namespace_scoped(
219            "ns-agent".into(),
220            pid(3),
221            vec!["/namespaces/prod".into()],
222        );
223        assert!(!view.can_read("/namespaces/staging/config"));
224    }
225
226    #[test]
227    fn none_scope_denies_everything() {
228        let view = AgentTreeView::new("isolated".into(), pid(4), TreeScope::None);
229        assert!(!view.can_read("/anything"));
230        assert!(!view.can_write("/anything"));
231    }
232
233    #[test]
234    fn assert_read_ok() {
235        let view = AgentTreeView::full_access("admin".into(), pid(1));
236        assert!(view.assert_read("/kernel/services").is_ok());
237    }
238
239    #[test]
240    fn assert_read_denied() {
241        let view = AgentTreeView::restricted("worker".into(), pid(2));
242        let result = view.assert_read("/kernel/secrets/key");
243        assert!(result.is_err());
244        let err = result.unwrap_err().to_string();
245        assert!(err.contains("denied"), "got: {err}");
246    }
247
248    #[test]
249    fn assert_write_ok() {
250        let view = AgentTreeView::restricted("worker".into(), pid(2));
251        assert!(view.assert_write("/agents/worker/data").is_ok());
252    }
253
254    #[test]
255    fn assert_write_denied() {
256        let view = AgentTreeView::restricted("worker".into(), pid(2));
257        let result = view.assert_write("/kernel/config/x");
258        assert!(result.is_err());
259    }
260
261    #[test]
262    fn restricted_write_to_own_subtree() {
263        let view = AgentTreeView::restricted("my-agent".into(), pid(5));
264        assert!(view.can_write("/agents/my-agent/data"));
265        assert!(!view.can_write("/agents/other-agent/data"));
266    }
267}