1#[cfg(test)]
8use chio_kernel::Verdict;
9use chio_kernel::{GuardContext, GuardDecision, KernelError};
10use glob::Pattern;
11
12use crate::action::{extract_action_checked, ToolAction};
13use crate::path_normalization::{
14 normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
15 normalize_path_for_policy_with_fs,
16};
17
18pub struct PathAllowlistConfig {
20 pub enabled: bool,
22 pub file_access_allow: Vec<String>,
24 pub file_write_allow: Vec<String>,
26 pub patch_allow: Vec<String>,
28}
29
30pub struct PathAllowlistGuard {
36 enabled: bool,
37 file_access_allow: Vec<Pattern>,
38 file_write_allow: Vec<Pattern>,
39 patch_allow: Vec<Pattern>,
40}
41
42impl PathAllowlistGuard {
43 pub fn new() -> Self {
44 Self::with_config(PathAllowlistConfig {
46 enabled: false,
47 file_access_allow: Vec::new(),
48 file_write_allow: Vec::new(),
49 patch_allow: Vec::new(),
50 })
51 }
52
53 pub fn with_config(config: PathAllowlistConfig) -> Self {
54 let file_access_allow: Vec<Pattern> = config
55 .file_access_allow
56 .iter()
57 .filter_map(|p| Pattern::new(p).ok())
58 .collect();
59 let file_write_allow: Vec<Pattern> = config
60 .file_write_allow
61 .iter()
62 .filter_map(|p| Pattern::new(p).ok())
63 .collect();
64 let patch_allow = if config.patch_allow.is_empty() {
65 file_write_allow.clone()
66 } else {
67 config
68 .patch_allow
69 .iter()
70 .filter_map(|p| Pattern::new(p).ok())
71 .collect()
72 };
73
74 Self {
75 enabled: config.enabled,
76 file_access_allow,
77 file_write_allow,
78 patch_allow,
79 }
80 }
81
82 fn matches_any(patterns: &[Pattern], path: &str) -> bool {
83 patterns.iter().any(|p| p.matches(path))
84 }
85
86 fn matches_allowlist(&self, patterns: &[Pattern], path: &str) -> bool {
87 let lexical_path = normalize_path_for_policy(path);
88 let resolved_path = normalize_path_for_policy_with_fs(path);
89 let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
90
91 let resolved_differs_from_lexical_target = lexical_abs_path
92 .as_deref()
93 .map(|abs| abs != resolved_path.as_str())
94 .unwrap_or(resolved_path != lexical_path);
95
96 if resolved_differs_from_lexical_target {
97 return Self::matches_any(patterns, &resolved_path);
100 }
101
102 Self::matches_any(patterns, &lexical_path)
103 || Self::matches_any(patterns, &resolved_path)
104 || lexical_abs_path
105 .as_deref()
106 .map(|abs| Self::matches_any(patterns, abs))
107 .unwrap_or(false)
108 }
109
110 fn path_within_root(candidate: &str, root: &str) -> bool {
111 if candidate == root {
112 return true;
113 }
114
115 if root == "/" {
116 return candidate.starts_with('/');
117 }
118
119 candidate
120 .strip_prefix(root)
121 .map(|suffix| suffix.starts_with('/'))
122 .unwrap_or(false)
123 }
124
125 fn matches_session_roots(&self, path: &str, session_roots: &[String]) -> bool {
126 if session_roots.is_empty() {
127 return false;
128 }
129
130 let lexical_path = normalize_path_for_policy(path);
131 let resolved_path = normalize_path_for_policy_with_fs(path);
132 let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
133 let resolved_differs_from_lexical_target = lexical_abs_path
134 .as_deref()
135 .map(|abs| abs != resolved_path.as_str())
136 .unwrap_or(resolved_path != lexical_path);
137
138 if resolved_differs_from_lexical_target {
139 return session_roots
140 .iter()
141 .any(|root| Self::path_within_root(&resolved_path, root));
142 }
143
144 session_roots.iter().any(|root| {
145 Self::path_within_root(&lexical_path, root)
146 || Self::path_within_root(&resolved_path, root)
147 || lexical_abs_path
148 .as_deref()
149 .map(|abs| Self::path_within_root(abs, root))
150 .unwrap_or(false)
151 })
152 }
153
154 pub fn is_file_access_allowed(&self, path: &str) -> bool {
155 if !self.enabled {
156 return true;
157 }
158 self.matches_allowlist(&self.file_access_allow, path)
159 }
160
161 pub fn is_file_write_allowed(&self, path: &str) -> bool {
162 if !self.enabled {
163 return true;
164 }
165 self.matches_allowlist(&self.file_write_allow, path)
166 }
167
168 pub fn is_patch_allowed(&self, path: &str) -> bool {
169 if !self.enabled {
170 return true;
171 }
172 self.matches_allowlist(&self.patch_allow, path)
173 }
174}
175
176impl Default for PathAllowlistGuard {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182impl chio_kernel::Guard for PathAllowlistGuard {
183 fn name(&self) -> &str {
184 "path-allowlist"
185 }
186
187 fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
188 if !self.enabled && ctx.session_filesystem_roots.is_none() {
189 return Ok(GuardDecision::allow());
190 }
191 let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
192 Ok(action) => action,
193 Err(_) => return Ok(GuardDecision::deny(Vec::new())),
194 };
195 let Some(path) = action.filesystem_path() else {
196 return Ok(GuardDecision::allow());
197 };
198
199 if let Some(session_roots) = ctx.session_filesystem_roots {
200 if !self.matches_session_roots(path, session_roots) {
201 return Ok(GuardDecision::deny(Vec::new()));
202 }
203 }
204
205 if !self.enabled {
206 return Ok(GuardDecision::allow());
207 }
208
209 let allowed = match &action {
210 ToolAction::FileAccess(path) => self.is_file_access_allowed(path),
211 ToolAction::FileWrite(path, _) => self.is_file_write_allowed(path),
212 ToolAction::Patch(path, _) => self.is_patch_allowed(path),
213 _ => false,
216 };
217
218 if allowed {
219 Ok(GuardDecision::allow())
220 } else {
221 Ok(GuardDecision::deny(Vec::new()))
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use chio_kernel::Guard;
230
231 fn enabled_config(
232 file_access: Vec<&str>,
233 file_write: Vec<&str>,
234 patch: Vec<&str>,
235 ) -> PathAllowlistConfig {
236 PathAllowlistConfig {
237 enabled: true,
238 file_access_allow: file_access.into_iter().map(String::from).collect(),
239 file_write_allow: file_write.into_iter().map(String::from).collect(),
240 patch_allow: patch.into_iter().map(String::from).collect(),
241 }
242 }
243
244 fn make_guard_context<'a>(
245 tool_name: &'a str,
246 arguments: serde_json::Value,
247 scope: &'a chio_core::capability::scope::ChioScope,
248 agent_id: &'a String,
249 server_id: &'a String,
250 capability: chio_core::capability::token::CapabilityToken,
251 session_roots: Option<&'a [String]>,
252 ) -> chio_kernel::GuardContext<'a> {
253 let request = Box::leak(Box::new(chio_kernel::ToolCallRequest {
254 request_id: "req-test".to_string(),
255 capability,
256 tool_name: tool_name.to_string(),
257 server_id: server_id.clone(),
258 agent_id: agent_id.clone(),
259 arguments,
260 dpop_proof: None,
261 execution_nonce: None,
262 governed_intent: None,
263 approval_token: None,
264 approval_tokens: Vec::new(),
265 threshold_approval_proposal: None,
266 supplemental_authorization: None,
267 model_metadata: None,
268 federated_origin_kernel_id: None,
269 }));
270
271 chio_kernel::GuardContext {
272 request,
273 scope,
274 agent_id,
275 server_id,
276 session_filesystem_roots: session_roots,
277 matched_grant_index: None,
278 }
279 }
280
281 fn make_test_capability(
282 kp: &chio_core::crypto::Keypair,
283 scope: &chio_core::capability::scope::ChioScope,
284 ) -> chio_core::capability::token::CapabilityToken {
285 let cap_body = chio_core::capability::token::CapabilityTokenBody {
286 id: "cap-test".to_string(),
287 issuer: kp.public_key(),
288 subject: kp.public_key(),
289 scope: scope.clone(),
290 issued_at: 0,
291 expires_at: u64::MAX,
292 delegation_chain: vec![],
293 aggregate_invocation_budget: None,
294 };
295 chio_core::capability::token::CapabilityToken::sign(cap_body, kp).expect("sign cap")
296 }
297
298 #[test]
299 fn allows_paths_inside_scope() {
300 let guard = PathAllowlistGuard::with_config(enabled_config(
301 vec!["**/repo/**"],
302 vec!["**/repo/**"],
303 vec![],
304 ));
305
306 assert!(guard.is_file_access_allowed("/tmp/repo/src/main.rs"));
307 assert!(guard.is_file_write_allowed("/tmp/repo/src/main.rs"));
308 assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
309 }
310
311 #[test]
312 fn denies_paths_outside_scope() {
313 let guard = PathAllowlistGuard::with_config(enabled_config(
314 vec!["**/repo/**"],
315 vec!["**/repo/**"],
316 vec![],
317 ));
318
319 assert!(!guard.is_file_access_allowed("/etc/passwd"));
320 assert!(!guard.is_file_write_allowed("/etc/passwd"));
321 assert!(!guard.is_patch_allowed("/etc/passwd"));
322 }
323
324 #[test]
325 fn patch_allow_falls_back_to_file_write_allow() {
326 let guard = PathAllowlistGuard::with_config(enabled_config(
327 vec![],
328 vec!["**/repo/**"],
329 vec![], ));
331 assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
332 assert!(!guard.is_patch_allowed("/tmp/other/src/main.rs"));
333 }
334
335 #[test]
336 fn explicit_patch_allow_does_not_fall_back() {
337 let guard = PathAllowlistGuard::with_config(enabled_config(
338 vec![],
339 vec!["**/repo/**"],
340 vec!["**/patches/**"],
341 ));
342 assert!(guard.is_patch_allowed("/tmp/patches/fix.diff"));
344 assert!(!guard.is_patch_allowed("/tmp/repo/src/main.rs"));
346 }
347
348 #[test]
349 fn disabled_guard_allows_everything() {
350 let guard = PathAllowlistGuard::new(); assert!(guard.is_file_access_allowed("/etc/shadow"));
352 assert!(guard.is_file_write_allowed("/etc/shadow"));
353 assert!(guard.is_patch_allowed("/etc/shadow"));
354 }
355
356 #[test]
357 fn disabled_guard_without_session_roots_allows_malformed_filesystem_actions() {
358 let guard = PathAllowlistGuard::new();
359 let kp = chio_core::crypto::Keypair::generate();
360 let scope = chio_core::capability::scope::ChioScope::default();
361 let agent_id = kp.public_key().to_hex();
362 let server_id = "srv-test".to_string();
363 let cap = make_test_capability(&kp, &scope);
364 let ctx = make_guard_context(
365 "read_file",
366 serde_json::json!({}),
367 &scope,
368 &agent_id,
369 &server_id,
370 cap,
371 None,
372 );
373
374 let result = guard.evaluate(&ctx).expect("evaluate should not error");
375 assert_eq!(result, Verdict::Allow);
376 }
377
378 #[test]
379 fn disabled_guard_with_session_roots_denies_malformed_filesystem_actions() {
380 let guard = PathAllowlistGuard::new();
381 let kp = chio_core::crypto::Keypair::generate();
382 let scope = chio_core::capability::scope::ChioScope::default();
383 let agent_id = kp.public_key().to_hex();
384 let server_id = "srv-test".to_string();
385 let cap = make_test_capability(&kp, &scope);
386 let session_roots = vec!["/workspace/project".to_string()];
387 let ctx = make_guard_context(
388 "read_file",
389 serde_json::json!({}),
390 &scope,
391 &agent_id,
392 &server_id,
393 cap,
394 Some(session_roots.as_slice()),
395 );
396
397 let result = guard.evaluate(&ctx).expect("evaluate should not error");
398 assert_eq!(result, Verdict::Deny);
399 }
400
401 #[test]
402 fn evaluate_denies_write_outside_allowlist() {
403 let guard = PathAllowlistGuard::with_config(enabled_config(
404 vec!["**/repo/**"],
405 vec!["**/repo/**"],
406 vec![],
407 ));
408
409 let kp = chio_core::crypto::Keypair::generate();
410 let scope = chio_core::capability::scope::ChioScope::default();
411 let agent_id = kp.public_key().to_hex();
412 let server_id = "srv-test".to_string();
413
414 let cap_body = chio_core::capability::token::CapabilityTokenBody {
415 id: "cap-test".to_string(),
416 issuer: kp.public_key(),
417 subject: kp.public_key(),
418 scope: scope.clone(),
419 issued_at: 0,
420 expires_at: u64::MAX,
421 delegation_chain: vec![],
422 aggregate_invocation_budget: None,
423 };
424 let cap =
425 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
426
427 let request = chio_kernel::ToolCallRequest {
428 request_id: "req-test".to_string(),
429 capability: cap,
430 tool_name: "write_file".to_string(),
431 server_id: server_id.clone(),
432 agent_id: agent_id.clone(),
433 arguments: serde_json::json!({"path": "/etc/passwd", "content": "bad"}),
434 dpop_proof: None,
435 execution_nonce: None,
436 governed_intent: None,
437 approval_token: None,
438 approval_tokens: Vec::new(),
439 threshold_approval_proposal: None,
440 supplemental_authorization: None,
441 model_metadata: None,
442 federated_origin_kernel_id: None,
443 };
444
445 let ctx = chio_kernel::GuardContext {
446 request: &request,
447 scope: &scope,
448 agent_id: &agent_id,
449 server_id: &server_id,
450 session_filesystem_roots: None,
451 matched_grant_index: None,
452 };
453
454 let result = guard.evaluate(&ctx).expect("evaluate should not error");
455 assert_eq!(result, Verdict::Deny);
456 }
457
458 #[test]
459 fn evaluate_denies_acp_write_text_file_outside_allowlist() {
460 let guard = PathAllowlistGuard::with_config(enabled_config(
461 vec!["**/repo/**"],
462 vec!["**/repo/**"],
463 vec![],
464 ));
465
466 let kp = chio_core::crypto::Keypair::generate();
467 let scope = chio_core::capability::scope::ChioScope::default();
468 let agent_id = kp.public_key().to_hex();
469 let server_id = "srv-test".to_string();
470
471 let cap_body = chio_core::capability::token::CapabilityTokenBody {
472 id: "cap-test".to_string(),
473 issuer: kp.public_key(),
474 subject: kp.public_key(),
475 scope: scope.clone(),
476 issued_at: 0,
477 expires_at: u64::MAX,
478 delegation_chain: vec![],
479 aggregate_invocation_budget: None,
480 };
481 let cap =
482 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
483
484 let request = chio_kernel::ToolCallRequest {
485 request_id: "req-test".to_string(),
486 capability: cap,
487 tool_name: "fs/write_text_file".to_string(),
488 server_id: server_id.clone(),
489 agent_id: agent_id.clone(),
490 arguments: serde_json::json!({
491 "sessionId": "sess-1",
492 "path": "/etc/passwd",
493 "content": "bad"
494 }),
495 dpop_proof: None,
496 execution_nonce: None,
497 governed_intent: None,
498 approval_token: None,
499 approval_tokens: Vec::new(),
500 threshold_approval_proposal: None,
501 supplemental_authorization: None,
502 model_metadata: None,
503 federated_origin_kernel_id: None,
504 };
505
506 let ctx = chio_kernel::GuardContext {
507 request: &request,
508 scope: &scope,
509 agent_id: &agent_id,
510 server_id: &server_id,
511 session_filesystem_roots: None,
512 matched_grant_index: None,
513 };
514
515 let result = guard.evaluate(&ctx).expect("evaluate should not error");
516 assert_eq!(result, Verdict::Deny);
517 }
518
519 #[cfg(unix)]
520 #[test]
521 fn symlink_escape_outside_allowlist_is_denied() {
522 use std::os::unix::fs::symlink;
523
524 let root = std::env::temp_dir().join(format!("chio-path-allowlist-{}", std::process::id()));
525 let allowed_dir = root.join("allowed");
526 let outside_dir = root.join("outside");
527 std::fs::create_dir_all(&allowed_dir).expect("create allowed dir");
528 std::fs::create_dir_all(&outside_dir).expect("create outside dir");
529
530 let target = outside_dir.join("secret.txt");
531 std::fs::write(&target, "sensitive").expect("write target");
532 let link = allowed_dir.join("link.txt");
533 symlink(&target, &link).expect("create symlink");
534
535 let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
536 enabled: true,
537 file_access_allow: vec![format!("{}/allowed/**", root.display())],
538 file_write_allow: vec![format!("{}/allowed/**", root.display())],
539 patch_allow: vec![],
540 });
541
542 assert!(
543 !guard.is_file_access_allowed(link.to_str().expect("utf-8 path")),
544 "symlink target outside allowlist must be denied"
545 );
546
547 let _ = std::fs::remove_dir_all(&root);
548 }
549
550 #[test]
551 fn session_roots_deny_out_of_root_access_even_when_allowlist_matches() {
552 let guard = PathAllowlistGuard::with_config(enabled_config(vec!["**"], vec!["**"], vec![]));
553 let kp = chio_core::crypto::Keypair::generate();
554 let scope = chio_core::capability::scope::ChioScope::default();
555 let agent_id = kp.public_key().to_hex();
556 let server_id = "srv-test".to_string();
557 let cap_body = chio_core::capability::token::CapabilityTokenBody {
558 id: "cap-test".to_string(),
559 issuer: kp.public_key(),
560 subject: kp.public_key(),
561 scope: scope.clone(),
562 issued_at: 0,
563 expires_at: u64::MAX,
564 delegation_chain: vec![],
565 aggregate_invocation_budget: None,
566 };
567 let cap =
568 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
569 let session_roots = vec!["/workspace/project".to_string()];
570 let ctx = make_guard_context(
571 "filesystem",
572 serde_json::json!({"path": "/etc/passwd"}),
573 &scope,
574 &agent_id,
575 &server_id,
576 cap,
577 Some(session_roots.as_slice()),
578 );
579
580 let result = guard.evaluate(&ctx).expect("evaluate should not error");
581 assert_eq!(result, Verdict::Deny);
582 }
583
584 #[test]
585 fn session_roots_fail_closed_when_root_set_is_empty() {
586 let guard = PathAllowlistGuard::new();
587 let kp = chio_core::crypto::Keypair::generate();
588 let scope = chio_core::capability::scope::ChioScope::default();
589 let agent_id = kp.public_key().to_hex();
590 let server_id = "srv-test".to_string();
591 let cap_body = chio_core::capability::token::CapabilityTokenBody {
592 id: "cap-test".to_string(),
593 issuer: kp.public_key(),
594 subject: kp.public_key(),
595 scope: scope.clone(),
596 issued_at: 0,
597 expires_at: u64::MAX,
598 delegation_chain: vec![],
599 aggregate_invocation_budget: None,
600 };
601 let cap =
602 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
603 let session_roots: Vec<String> = Vec::new();
604 let ctx = make_guard_context(
605 "filesystem",
606 serde_json::json!({"path": "/workspace/project/src/lib.rs"}),
607 &scope,
608 &agent_id,
609 &server_id,
610 cap,
611 Some(session_roots.as_slice()),
612 );
613
614 let result = guard.evaluate(&ctx).expect("evaluate should not error");
615 assert_eq!(result, Verdict::Deny);
616 }
617
618 #[test]
619 fn session_roots_allow_in_root_access_when_other_checks_pass() {
620 let guard = PathAllowlistGuard::new();
621 let kp = chio_core::crypto::Keypair::generate();
622 let scope = chio_core::capability::scope::ChioScope::default();
623 let agent_id = kp.public_key().to_hex();
624 let server_id = "srv-test".to_string();
625 let cap_body = chio_core::capability::token::CapabilityTokenBody {
626 id: "cap-test".to_string(),
627 issuer: kp.public_key(),
628 subject: kp.public_key(),
629 scope: scope.clone(),
630 issued_at: 0,
631 expires_at: u64::MAX,
632 delegation_chain: vec![],
633 aggregate_invocation_budget: None,
634 };
635 let cap =
636 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
637 let session_roots = vec!["/workspace/project".to_string()];
638 let ctx = make_guard_context(
639 "filesystem",
640 serde_json::json!({"path": "/workspace/project/src/lib.rs"}),
641 &scope,
642 &agent_id,
643 &server_id,
644 cap,
645 Some(session_roots.as_slice()),
646 );
647
648 let result = guard.evaluate(&ctx).expect("evaluate should not error");
649 assert_eq!(result, Verdict::Allow);
650 }
651}