Skip to main content

chio_guards/
path_allowlist.rs

1//! Path allowlist guard -- deny by default when enabled.
2//!
3//! If a path is NOT in the allowlist, the guard denies the request. Separate
4//! allowlists for file access, file write, and patch operations. When
5//! `patch_allow` is empty, it falls back to `file_write_allow`.
6
7#[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
18/// Configuration for `PathAllowlistGuard`.
19pub struct PathAllowlistConfig {
20    /// Enable/disable this guard.
21    pub enabled: bool,
22    /// Allowed globs for file access operations.
23    pub file_access_allow: Vec<String>,
24    /// Allowed globs for file write operations.
25    pub file_write_allow: Vec<String>,
26    /// Allowed globs for patch operations (falls back to `file_write_allow` when empty).
27    pub patch_allow: Vec<String>,
28}
29
30/// Guard that restricts filesystem access to explicitly allowed paths.
31///
32/// When enabled, any file access, write, or patch to a path not matching the
33/// corresponding allowlist is denied. When disabled, the guard returns Allow
34/// for all requests.
35pub 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        // Disabled by default (allowlist-based guard must be explicitly configured).
45        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            // When resolution changes the target (e.g. symlink traversal), require the
98            // resolved path to match to prevent lexical-path allowlist bypasses.
99            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            // Fail closed: any path-bearing action this allowlist cannot
214            // classify is denied, never silently allowed.
215            _ => 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![], // empty patch_allow falls back to file_write_allow
330        ));
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        // Matches patch_allow, not file_write_allow.
343        assert!(guard.is_patch_allowed("/tmp/patches/fix.diff"));
344        // Does NOT match patch_allow even though it matches file_write_allow.
345        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(); // disabled by default
351        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}