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)
98                    || path.starts_with("/kernel/config/") // read-only config access
99            }
100            TreeScope::Namespace {
101                agent_path,
102                namespaces,
103            } => {
104                path.starts_with(agent_path)
105                    || namespaces.iter().any(|ns| path.starts_with(ns))
106            }
107            TreeScope::None => false,
108        }
109    }
110
111    /// Check whether a path is authorized for writing.
112    pub fn can_write(&self, path: &str) -> bool {
113        match &self.scope {
114            TreeScope::Full => true,
115            TreeScope::Restricted { agent_path } => {
116                // Restricted agents can only write within their own subtree.
117                path.starts_with(agent_path)
118            }
119            TreeScope::Namespace {
120                agent_path,
121                namespaces,
122            } => {
123                path.starts_with(agent_path)
124                    || namespaces.iter().any(|ns| path.starts_with(ns))
125            }
126            TreeScope::None => false,
127        }
128    }
129
130    /// Assert read access or return a permission error.
131    pub fn assert_read(&self, path: &str) -> Result<(), KernelError> {
132        if self.can_read(path) {
133            Ok(())
134        } else {
135            Err(KernelError::CapabilityDenied {
136                pid: self.pid,
137                action: format!("read path '{path}'"),
138                reason: format!(
139                    "agent '{}' not authorized for path '{path}'",
140                    self.agent_id
141                ),
142            })
143        }
144    }
145
146    /// Assert write access or return a permission error.
147    pub fn assert_write(&self, path: &str) -> Result<(), KernelError> {
148        if self.can_write(path) {
149            Ok(())
150        } else {
151            Err(KernelError::CapabilityDenied {
152                pid: self.pid,
153                action: format!("write path '{path}'"),
154                reason: format!(
155                    "agent '{}' not authorized to write path '{path}'",
156                    self.agent_id
157                ),
158            })
159        }
160    }
161}
162
163// ── Tests ─────────────────────────────────────────────────────────────────
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn pid(n: u64) -> Pid {
170        n
171    }
172
173    #[test]
174    fn full_access_reads_everything() {
175        let view = AgentTreeView::full_access("admin".into(), pid(1));
176        assert!(view.can_read("/kernel/services/health"));
177        assert!(view.can_read("/agents/other"));
178        assert!(view.can_read("/kernel/secrets/api"));
179    }
180
181    #[test]
182    fn full_access_writes_everything() {
183        let view = AgentTreeView::full_access("admin".into(), pid(1));
184        assert!(view.can_write("/kernel/config/app/key"));
185        assert!(view.can_write("/agents/admin/state"));
186    }
187
188    #[test]
189    fn restricted_sees_own_subtree() {
190        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
191        assert!(view.can_read("/agents/worker-1/state"));
192        assert!(view.can_read("/agents/worker-1/logs/entry"));
193    }
194
195    #[test]
196    fn restricted_sees_config_readonly() {
197        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
198        assert!(view.can_read("/kernel/config/app/timeout"));
199        assert!(!view.can_write("/kernel/config/app/timeout"));
200    }
201
202    #[test]
203    fn restricted_cannot_see_other_agents() {
204        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
205        assert!(!view.can_read("/agents/worker-2/state"));
206    }
207
208    #[test]
209    fn restricted_cannot_see_secrets() {
210        let view = AgentTreeView::restricted("worker-1".into(), pid(2));
211        assert!(!view.can_read("/kernel/secrets/api_key"));
212    }
213
214    #[test]
215    fn namespace_scoped_sees_namespaces() {
216        let view = AgentTreeView::namespace_scoped(
217            "ns-agent".into(),
218            pid(3),
219            vec!["/namespaces/prod".into()],
220        );
221        assert!(view.can_read("/agents/ns-agent/state"));
222        assert!(view.can_read("/namespaces/prod/config"));
223        assert!(view.can_write("/namespaces/prod/data"));
224    }
225
226    #[test]
227    fn namespace_scoped_cannot_see_other_namespaces() {
228        let view = AgentTreeView::namespace_scoped(
229            "ns-agent".into(),
230            pid(3),
231            vec!["/namespaces/prod".into()],
232        );
233        assert!(!view.can_read("/namespaces/staging/config"));
234    }
235
236    #[test]
237    fn none_scope_denies_everything() {
238        let view = AgentTreeView::new("isolated".into(), pid(4), TreeScope::None);
239        assert!(!view.can_read("/anything"));
240        assert!(!view.can_write("/anything"));
241    }
242
243    #[test]
244    fn assert_read_ok() {
245        let view = AgentTreeView::full_access("admin".into(), pid(1));
246        assert!(view.assert_read("/kernel/services").is_ok());
247    }
248
249    #[test]
250    fn assert_read_denied() {
251        let view = AgentTreeView::restricted("worker".into(), pid(2));
252        let result = view.assert_read("/kernel/secrets/key");
253        assert!(result.is_err());
254        let err = result.unwrap_err().to_string();
255        assert!(err.contains("denied"), "got: {err}");
256    }
257
258    #[test]
259    fn assert_write_ok() {
260        let view = AgentTreeView::restricted("worker".into(), pid(2));
261        assert!(view.assert_write("/agents/worker/data").is_ok());
262    }
263
264    #[test]
265    fn assert_write_denied() {
266        let view = AgentTreeView::restricted("worker".into(), pid(2));
267        let result = view.assert_write("/kernel/config/x");
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn restricted_write_to_own_subtree() {
273        let view = AgentTreeView::restricted("my-agent".into(), pid(5));
274        assert!(view.can_write("/agents/my-agent/data"));
275        assert!(!view.can_write("/agents/other-agent/data"));
276    }
277}