Skip to main content

chio_guards/
forbidden_path.rs

1//! Forbidden path guard -- blocks access to sensitive filesystem paths.
2//!
3//! Adapted from ClawdStrike's `guards/forbidden_path.rs`.  The pattern
4//! matching and path normalization logic is intentionally identical.
5
6use chio_kernel::{GuardContext, KernelError, Verdict};
7use glob::Pattern;
8
9use crate::action::{extract_action, ToolAction};
10use crate::path_normalization::{
11    normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
12    normalize_path_for_policy_with_fs,
13};
14
15fn default_forbidden_patterns() -> Vec<String> {
16    let mut patterns = vec![
17        // SSH keys
18        "**/.ssh/**".to_string(),
19        "**/id_rsa*".to_string(),
20        "**/id_ed25519*".to_string(),
21        "**/id_ecdsa*".to_string(),
22        // AWS credentials
23        "**/.aws/**".to_string(),
24        // Environment files
25        "**/.env".to_string(),
26        "**/.env.*".to_string(),
27        // Git credentials
28        "**/.git-credentials".to_string(),
29        "**/.gitconfig".to_string(),
30        // GPG keys
31        "**/.gnupg/**".to_string(),
32        // Kubernetes
33        "**/.kube/**".to_string(),
34        // Docker
35        "**/.docker/**".to_string(),
36        // NPM tokens
37        "**/.npmrc".to_string(),
38        // Password stores
39        "**/.password-store/**".to_string(),
40        "**/pass/**".to_string(),
41        // 1Password
42        "**/.1password/**".to_string(),
43        // System paths (Unix)
44        "/etc/shadow".to_string(),
45        "/etc/passwd".to_string(),
46        "/etc/sudoers".to_string(),
47    ];
48
49    // Windows paths -- on non-Windows these globs simply never match.
50    patterns.extend([
51        "**/AppData/Roaming/Microsoft/Credentials/**".to_string(),
52        "**/AppData/Local/Microsoft/Credentials/**".to_string(),
53        "**/AppData/Roaming/Microsoft/Vault/**".to_string(),
54        "**/NTUSER.DAT".to_string(),
55        "**/NTUSER.DAT.*".to_string(),
56        "**/Windows/System32/config/SAM".to_string(),
57        "**/Windows/System32/config/SECURITY".to_string(),
58        "**/Windows/System32/config/SYSTEM".to_string(),
59        "**/*.reg".to_string(),
60        "**/AppData/Roaming/Microsoft/SystemCertificates/**".to_string(),
61        "**/WindowsPowerShell/profile.ps1".to_string(),
62        "**/PowerShell/profile.ps1".to_string(),
63    ]);
64
65    patterns
66}
67
68/// Guard that blocks access to sensitive filesystem paths.
69pub struct ForbiddenPathGuard {
70    patterns: Vec<Pattern>,
71    exceptions: Vec<Pattern>,
72}
73
74impl ForbiddenPathGuard {
75    pub fn new() -> Self {
76        Self::with_patterns(default_forbidden_patterns(), vec![])
77    }
78
79    pub fn with_patterns(patterns: Vec<String>, exceptions: Vec<String>) -> Self {
80        let patterns = patterns
81            .iter()
82            .filter_map(|p| Pattern::new(p).ok())
83            .collect();
84        let exceptions = exceptions
85            .iter()
86            .filter_map(|p| Pattern::new(p).ok())
87            .collect();
88        Self {
89            patterns,
90            exceptions,
91        }
92    }
93
94    pub fn is_forbidden(&self, path: &str) -> bool {
95        let lexical_path = normalize_path_for_policy(path);
96        let resolved_path = normalize_path_for_policy_with_fs(path);
97        let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
98        let resolved_differs_from_lexical_target = lexical_abs_path
99            .as_deref()
100            .map(|abs| abs != resolved_path.as_str())
101            .unwrap_or(resolved_path != lexical_path);
102
103        // Check exceptions first
104        for exception in &self.exceptions {
105            let lexical_matches = exception.matches(&lexical_path)
106                || lexical_abs_path
107                    .as_deref()
108                    .map(|abs| exception.matches(abs))
109                    .unwrap_or(false);
110            let resolved_matches = exception.matches(&resolved_path);
111            let exception_matches = if resolved_differs_from_lexical_target {
112                resolved_matches
113            } else {
114                resolved_matches || lexical_matches
115            };
116
117            if exception_matches {
118                return false;
119            }
120        }
121
122        // Check forbidden patterns
123        for pattern in &self.patterns {
124            if pattern.matches(&resolved_path) || pattern.matches(&lexical_path) {
125                return true;
126            }
127        }
128
129        false
130    }
131}
132
133impl Default for ForbiddenPathGuard {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl chio_kernel::Guard for ForbiddenPathGuard {
140    fn name(&self) -> &str {
141        "forbidden-path"
142    }
143
144    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
145        let action = extract_action(&ctx.request.tool_name, &ctx.request.arguments);
146
147        let path = match &action {
148            ToolAction::FileAccess(p) | ToolAction::FileWrite(p, _) | ToolAction::Patch(p, _) => {
149                Some(p.as_str())
150            }
151            _ => None,
152        };
153
154        let Some(path) = path else {
155            return Ok(Verdict::Allow);
156        };
157
158        if self.is_forbidden(path) {
159            Ok(Verdict::Deny)
160        } else {
161            Ok(Verdict::Allow)
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn blocks_ssh_keys() {
172        let guard = ForbiddenPathGuard::new();
173        assert!(guard.is_forbidden("/home/user/.ssh/id_rsa"));
174        assert!(guard.is_forbidden("/home/user/.ssh/authorized_keys"));
175    }
176
177    #[test]
178    fn blocks_etc_shadow() {
179        let guard = ForbiddenPathGuard::new();
180        assert!(guard.is_forbidden("/etc/shadow"));
181    }
182
183    #[test]
184    fn blocks_aws_credentials() {
185        let guard = ForbiddenPathGuard::new();
186        assert!(guard.is_forbidden("/home/user/.aws/credentials"));
187    }
188
189    #[test]
190    fn blocks_env_files() {
191        let guard = ForbiddenPathGuard::new();
192        assert!(guard.is_forbidden("/app/.env"));
193        assert!(guard.is_forbidden("/app/.env.local"));
194    }
195
196    #[test]
197    fn allows_normal_files() {
198        let guard = ForbiddenPathGuard::new();
199        assert!(!guard.is_forbidden("/home/user/project/src/main.rs"));
200        assert!(!guard.is_forbidden("/home/user/project/README.md"));
201        assert!(!guard.is_forbidden("/app/src/main.rs"));
202    }
203
204    #[test]
205    fn exceptions_work() {
206        let guard = ForbiddenPathGuard::with_patterns(
207            vec!["**/.env".to_string()],
208            vec!["**/project/.env".to_string()],
209        );
210        assert!(guard.is_forbidden("/app/.env"));
211        assert!(!guard.is_forbidden("/app/project/.env"));
212    }
213
214    #[test]
215    fn windows_paths_normalized() {
216        let guard = ForbiddenPathGuard::new();
217        assert!(guard.is_forbidden(r"C:\Users\alice\.ssh\id_rsa"));
218        assert!(guard.is_forbidden(r"C:\Users\bob\.aws\credentials"));
219        assert!(!guard.is_forbidden(r"C:\Users\alice\Documents\report.docx"));
220    }
221}