clawft_kernel/
tree_view.rs1use serde::{Deserialize, Serialize};
11
12use crate::error::KernelError;
13use crate::process::Pid;
14
15#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub enum TreeScope {
23 Full,
25 Restricted {
27 agent_path: String,
29 },
30 Namespace {
32 agent_path: String,
34 namespaces: Vec<String>,
36 },
37 None,
39}
40
41pub struct AgentTreeView {
50 pub agent_id: String,
52 pub pid: Pid,
54 pub scope: TreeScope,
56}
57
58impl AgentTreeView {
59 pub fn new(agent_id: String, pid: Pid, scope: TreeScope) -> Self {
61 Self {
62 agent_id,
63 pid,
64 scope,
65 }
66 }
67
68 pub fn full_access(agent_id: String, pid: Pid) -> Self {
70 Self::new(agent_id, pid, TreeScope::Full)
71 }
72
73 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 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 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/") }
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 pub fn can_write(&self, path: &str) -> bool {
113 match &self.scope {
114 TreeScope::Full => true,
115 TreeScope::Restricted { agent_path } => {
116 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 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 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#[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}