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) || path.starts_with("/kernel/config/") }
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 pub fn can_write(&self, path: &str) -> bool {
109 match &self.scope {
110 TreeScope::Full => true,
111 TreeScope::Restricted { agent_path } => {
112 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 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 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#[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}