1use chio_kernel::{GuardContext, KernelError, Verdict};
9use glob::Pattern;
10
11use crate::action::{extract_action, ToolAction};
12use crate::path_normalization::{
13 normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
14 normalize_path_for_policy_with_fs,
15};
16
17pub struct PathAllowlistConfig {
19 pub enabled: bool,
21 pub file_access_allow: Vec<String>,
23 pub file_write_allow: Vec<String>,
25 pub patch_allow: Vec<String>,
27}
28
29pub struct PathAllowlistGuard {
35 enabled: bool,
36 file_access_allow: Vec<Pattern>,
37 file_write_allow: Vec<Pattern>,
38 patch_allow: Vec<Pattern>,
39}
40
41impl PathAllowlistGuard {
42 pub fn new() -> Self {
43 Self::with_config(PathAllowlistConfig {
45 enabled: false,
46 file_access_allow: Vec::new(),
47 file_write_allow: Vec::new(),
48 patch_allow: Vec::new(),
49 })
50 }
51
52 pub fn with_config(config: PathAllowlistConfig) -> Self {
53 let file_access_allow: Vec<Pattern> = config
54 .file_access_allow
55 .iter()
56 .filter_map(|p| Pattern::new(p).ok())
57 .collect();
58 let file_write_allow: Vec<Pattern> = config
59 .file_write_allow
60 .iter()
61 .filter_map(|p| Pattern::new(p).ok())
62 .collect();
63 let patch_allow = if config.patch_allow.is_empty() {
64 file_write_allow.clone()
65 } else {
66 config
67 .patch_allow
68 .iter()
69 .filter_map(|p| Pattern::new(p).ok())
70 .collect()
71 };
72
73 Self {
74 enabled: config.enabled,
75 file_access_allow,
76 file_write_allow,
77 patch_allow,
78 }
79 }
80
81 fn matches_any(patterns: &[Pattern], path: &str) -> bool {
82 patterns.iter().any(|p| p.matches(path))
83 }
84
85 fn matches_allowlist(&self, patterns: &[Pattern], path: &str) -> bool {
86 let lexical_path = normalize_path_for_policy(path);
87 let resolved_path = normalize_path_for_policy_with_fs(path);
88 let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
89
90 let resolved_differs_from_lexical_target = lexical_abs_path
91 .as_deref()
92 .map(|abs| abs != resolved_path.as_str())
93 .unwrap_or(resolved_path != lexical_path);
94
95 if resolved_differs_from_lexical_target {
96 return Self::matches_any(patterns, &resolved_path);
99 }
100
101 Self::matches_any(patterns, &lexical_path)
102 || Self::matches_any(patterns, &resolved_path)
103 || lexical_abs_path
104 .as_deref()
105 .map(|abs| Self::matches_any(patterns, abs))
106 .unwrap_or(false)
107 }
108
109 fn path_within_root(candidate: &str, root: &str) -> bool {
110 if candidate == root {
111 return true;
112 }
113
114 if root == "/" {
115 return candidate.starts_with('/');
116 }
117
118 candidate
119 .strip_prefix(root)
120 .map(|suffix| suffix.starts_with('/'))
121 .unwrap_or(false)
122 }
123
124 fn matches_session_roots(&self, path: &str, session_roots: &[String]) -> bool {
125 if session_roots.is_empty() {
126 return false;
127 }
128
129 let lexical_path = normalize_path_for_policy(path);
130 let resolved_path = normalize_path_for_policy_with_fs(path);
131 let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
132 let resolved_differs_from_lexical_target = lexical_abs_path
133 .as_deref()
134 .map(|abs| abs != resolved_path.as_str())
135 .unwrap_or(resolved_path != lexical_path);
136
137 if resolved_differs_from_lexical_target {
138 return session_roots
139 .iter()
140 .any(|root| Self::path_within_root(&resolved_path, root));
141 }
142
143 session_roots.iter().any(|root| {
144 Self::path_within_root(&lexical_path, root)
145 || Self::path_within_root(&resolved_path, root)
146 || lexical_abs_path
147 .as_deref()
148 .map(|abs| Self::path_within_root(abs, root))
149 .unwrap_or(false)
150 })
151 }
152
153 pub fn is_file_access_allowed(&self, path: &str) -> bool {
154 if !self.enabled {
155 return true;
156 }
157 self.matches_allowlist(&self.file_access_allow, path)
158 }
159
160 pub fn is_file_write_allowed(&self, path: &str) -> bool {
161 if !self.enabled {
162 return true;
163 }
164 self.matches_allowlist(&self.file_write_allow, path)
165 }
166
167 pub fn is_patch_allowed(&self, path: &str) -> bool {
168 if !self.enabled {
169 return true;
170 }
171 self.matches_allowlist(&self.patch_allow, path)
172 }
173}
174
175impl Default for PathAllowlistGuard {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181impl chio_kernel::Guard for PathAllowlistGuard {
182 fn name(&self) -> &str {
183 "path-allowlist"
184 }
185
186 fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
187 let action = extract_action(&ctx.request.tool_name, &ctx.request.arguments);
188 let Some(path) = action.filesystem_path() else {
189 return Ok(Verdict::Allow);
190 };
191
192 if let Some(session_roots) = ctx.session_filesystem_roots {
193 if !self.matches_session_roots(path, session_roots) {
194 return Ok(Verdict::Deny);
195 }
196 }
197
198 if !self.enabled {
199 return Ok(Verdict::Allow);
200 }
201
202 let allowed = match &action {
203 ToolAction::FileAccess(path) => self.is_file_access_allowed(path),
204 ToolAction::FileWrite(path, _) => self.is_file_write_allowed(path),
205 ToolAction::Patch(path, _) => self.is_patch_allowed(path),
206 _ => unreachable!("non-filesystem actions should return early"),
207 };
208
209 if allowed {
210 Ok(Verdict::Allow)
211 } else {
212 Ok(Verdict::Deny)
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use chio_kernel::Guard;
221
222 fn enabled_config(
223 file_access: Vec<&str>,
224 file_write: Vec<&str>,
225 patch: Vec<&str>,
226 ) -> PathAllowlistConfig {
227 PathAllowlistConfig {
228 enabled: true,
229 file_access_allow: file_access.into_iter().map(String::from).collect(),
230 file_write_allow: file_write.into_iter().map(String::from).collect(),
231 patch_allow: patch.into_iter().map(String::from).collect(),
232 }
233 }
234
235 fn make_guard_context<'a>(
236 tool_name: &'a str,
237 arguments: serde_json::Value,
238 scope: &'a chio_core::capability::ChioScope,
239 agent_id: &'a String,
240 server_id: &'a String,
241 capability: chio_core::capability::CapabilityToken,
242 session_roots: Option<&'a [String]>,
243 ) -> chio_kernel::GuardContext<'a> {
244 let request = Box::leak(Box::new(chio_kernel::ToolCallRequest {
245 request_id: "req-test".to_string(),
246 capability,
247 tool_name: tool_name.to_string(),
248 server_id: server_id.clone(),
249 agent_id: agent_id.clone(),
250 arguments,
251 dpop_proof: None,
252 governed_intent: None,
253 approval_token: None,
254 model_metadata: None,
255 federated_origin_kernel_id: None,
256 }));
257
258 chio_kernel::GuardContext {
259 request,
260 scope,
261 agent_id,
262 server_id,
263 session_filesystem_roots: session_roots,
264 matched_grant_index: None,
265 }
266 }
267
268 #[test]
269 fn allows_paths_inside_scope() {
270 let guard = PathAllowlistGuard::with_config(enabled_config(
271 vec!["**/repo/**"],
272 vec!["**/repo/**"],
273 vec![],
274 ));
275
276 assert!(guard.is_file_access_allowed("/tmp/repo/src/main.rs"));
277 assert!(guard.is_file_write_allowed("/tmp/repo/src/main.rs"));
278 assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
279 }
280
281 #[test]
282 fn denies_paths_outside_scope() {
283 let guard = PathAllowlistGuard::with_config(enabled_config(
284 vec!["**/repo/**"],
285 vec!["**/repo/**"],
286 vec![],
287 ));
288
289 assert!(!guard.is_file_access_allowed("/etc/passwd"));
290 assert!(!guard.is_file_write_allowed("/etc/passwd"));
291 assert!(!guard.is_patch_allowed("/etc/passwd"));
292 }
293
294 #[test]
295 fn patch_allow_falls_back_to_file_write_allow() {
296 let guard = PathAllowlistGuard::with_config(enabled_config(
297 vec![],
298 vec!["**/repo/**"],
299 vec![], ));
301 assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
302 assert!(!guard.is_patch_allowed("/tmp/other/src/main.rs"));
303 }
304
305 #[test]
306 fn explicit_patch_allow_does_not_fall_back() {
307 let guard = PathAllowlistGuard::with_config(enabled_config(
308 vec![],
309 vec!["**/repo/**"],
310 vec!["**/patches/**"],
311 ));
312 assert!(guard.is_patch_allowed("/tmp/patches/fix.diff"));
314 assert!(!guard.is_patch_allowed("/tmp/repo/src/main.rs"));
316 }
317
318 #[test]
319 fn disabled_guard_allows_everything() {
320 let guard = PathAllowlistGuard::new(); assert!(guard.is_file_access_allowed("/etc/shadow"));
322 assert!(guard.is_file_write_allowed("/etc/shadow"));
323 assert!(guard.is_patch_allowed("/etc/shadow"));
324 }
325
326 #[test]
327 fn evaluate_denies_write_outside_allowlist() {
328 let guard = PathAllowlistGuard::with_config(enabled_config(
329 vec!["**/repo/**"],
330 vec!["**/repo/**"],
331 vec![],
332 ));
333
334 let kp = chio_core::crypto::Keypair::generate();
335 let scope = chio_core::capability::ChioScope::default();
336 let agent_id = kp.public_key().to_hex();
337 let server_id = "srv-test".to_string();
338
339 let cap_body = chio_core::capability::CapabilityTokenBody {
340 id: "cap-test".to_string(),
341 issuer: kp.public_key(),
342 subject: kp.public_key(),
343 scope: scope.clone(),
344 issued_at: 0,
345 expires_at: u64::MAX,
346 delegation_chain: vec![],
347 };
348 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
349
350 let request = chio_kernel::ToolCallRequest {
351 request_id: "req-test".to_string(),
352 capability: cap,
353 tool_name: "write_file".to_string(),
354 server_id: server_id.clone(),
355 agent_id: agent_id.clone(),
356 arguments: serde_json::json!({"path": "/etc/passwd", "content": "bad"}),
357 dpop_proof: None,
358 governed_intent: None,
359 approval_token: None,
360 model_metadata: None,
361 federated_origin_kernel_id: None,
362 };
363
364 let ctx = chio_kernel::GuardContext {
365 request: &request,
366 scope: &scope,
367 agent_id: &agent_id,
368 server_id: &server_id,
369 session_filesystem_roots: None,
370 matched_grant_index: None,
371 };
372
373 let result = guard.evaluate(&ctx).expect("evaluate should not error");
374 assert_eq!(result, Verdict::Deny);
375 }
376
377 #[cfg(unix)]
378 #[test]
379 fn symlink_escape_outside_allowlist_is_denied() {
380 use std::os::unix::fs::symlink;
381
382 let root = std::env::temp_dir().join(format!("chio-path-allowlist-{}", std::process::id()));
383 let allowed_dir = root.join("allowed");
384 let outside_dir = root.join("outside");
385 std::fs::create_dir_all(&allowed_dir).expect("create allowed dir");
386 std::fs::create_dir_all(&outside_dir).expect("create outside dir");
387
388 let target = outside_dir.join("secret.txt");
389 std::fs::write(&target, "sensitive").expect("write target");
390 let link = allowed_dir.join("link.txt");
391 symlink(&target, &link).expect("create symlink");
392
393 let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
394 enabled: true,
395 file_access_allow: vec![format!("{}/allowed/**", root.display())],
396 file_write_allow: vec![format!("{}/allowed/**", root.display())],
397 patch_allow: vec![],
398 });
399
400 assert!(
401 !guard.is_file_access_allowed(link.to_str().expect("utf-8 path")),
402 "symlink target outside allowlist must be denied"
403 );
404
405 let _ = std::fs::remove_dir_all(&root);
406 }
407
408 #[test]
409 fn session_roots_deny_out_of_root_access_even_when_allowlist_matches() {
410 let guard = PathAllowlistGuard::with_config(enabled_config(vec!["**"], vec!["**"], vec![]));
411 let kp = chio_core::crypto::Keypair::generate();
412 let scope = chio_core::capability::ChioScope::default();
413 let agent_id = kp.public_key().to_hex();
414 let server_id = "srv-test".to_string();
415 let cap_body = chio_core::capability::CapabilityTokenBody {
416 id: "cap-test".to_string(),
417 issuer: kp.public_key(),
418 subject: kp.public_key(),
419 scope: scope.clone(),
420 issued_at: 0,
421 expires_at: u64::MAX,
422 delegation_chain: vec![],
423 };
424 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
425 let session_roots = vec!["/workspace/project".to_string()];
426 let ctx = make_guard_context(
427 "filesystem",
428 serde_json::json!({"path": "/etc/passwd"}),
429 &scope,
430 &agent_id,
431 &server_id,
432 cap,
433 Some(session_roots.as_slice()),
434 );
435
436 let result = guard.evaluate(&ctx).expect("evaluate should not error");
437 assert_eq!(result, Verdict::Deny);
438 }
439
440 #[test]
441 fn session_roots_fail_closed_when_root_set_is_empty() {
442 let guard = PathAllowlistGuard::new();
443 let kp = chio_core::crypto::Keypair::generate();
444 let scope = chio_core::capability::ChioScope::default();
445 let agent_id = kp.public_key().to_hex();
446 let server_id = "srv-test".to_string();
447 let cap_body = chio_core::capability::CapabilityTokenBody {
448 id: "cap-test".to_string(),
449 issuer: kp.public_key(),
450 subject: kp.public_key(),
451 scope: scope.clone(),
452 issued_at: 0,
453 expires_at: u64::MAX,
454 delegation_chain: vec![],
455 };
456 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
457 let session_roots: Vec<String> = Vec::new();
458 let ctx = make_guard_context(
459 "filesystem",
460 serde_json::json!({"path": "/workspace/project/src/lib.rs"}),
461 &scope,
462 &agent_id,
463 &server_id,
464 cap,
465 Some(session_roots.as_slice()),
466 );
467
468 let result = guard.evaluate(&ctx).expect("evaluate should not error");
469 assert_eq!(result, Verdict::Deny);
470 }
471
472 #[test]
473 fn session_roots_allow_in_root_access_when_other_checks_pass() {
474 let guard = PathAllowlistGuard::new();
475 let kp = chio_core::crypto::Keypair::generate();
476 let scope = chio_core::capability::ChioScope::default();
477 let agent_id = kp.public_key().to_hex();
478 let server_id = "srv-test".to_string();
479 let cap_body = chio_core::capability::CapabilityTokenBody {
480 id: "cap-test".to_string(),
481 issuer: kp.public_key(),
482 subject: kp.public_key(),
483 scope: scope.clone(),
484 issued_at: 0,
485 expires_at: u64::MAX,
486 delegation_chain: vec![],
487 };
488 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
489 let session_roots = vec!["/workspace/project".to_string()];
490 let ctx = make_guard_context(
491 "filesystem",
492 serde_json::json!({"path": "/workspace/project/src/lib.rs"}),
493 &scope,
494 &agent_id,
495 &server_id,
496 cap,
497 Some(session_roots.as_slice()),
498 );
499
500 let result = guard.evaluate(&ctx).expect("evaluate should not error");
501 assert_eq!(result, Verdict::Allow);
502 }
503}