Skip to main content

chio_guards/
path_allowlist.rs

1//! Path allowlist guard -- deny by default when enabled.
2//!
3//! Adapted from ClawdStrike's `guards/path_allowlist.rs`. If a path is NOT in
4//! the allowlist, the guard denies the request. Separate allowlists for file
5//! access, file write, and patch operations. When `patch_allow` is empty, it
6//! falls back to `file_write_allow`.
7
8use 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
17/// Configuration for `PathAllowlistGuard`.
18pub struct PathAllowlistConfig {
19    /// Enable/disable this guard.
20    pub enabled: bool,
21    /// Allowed globs for file access operations.
22    pub file_access_allow: Vec<String>,
23    /// Allowed globs for file write operations.
24    pub file_write_allow: Vec<String>,
25    /// Allowed globs for patch operations (falls back to `file_write_allow` when empty).
26    pub patch_allow: Vec<String>,
27}
28
29/// Guard that restricts filesystem access to explicitly allowed paths.
30///
31/// When enabled, any file access, write, or patch to a path not matching the
32/// corresponding allowlist is denied. When disabled, the guard returns Allow
33/// for all requests.
34pub 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        // Disabled by default (allowlist-based guard must be explicitly configured).
44        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            // When resolution changes the target (e.g. symlink traversal), require the
97            // resolved path to match to prevent lexical-path allowlist bypasses.
98            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![], // empty patch_allow falls back to file_write_allow
300        ));
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        // Matches patch_allow, not file_write_allow.
313        assert!(guard.is_patch_allowed("/tmp/patches/fix.diff"));
314        // Does NOT match patch_allow even though it matches file_write_allow.
315        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(); // disabled by default
321        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}