Skip to main content

chio_guards/
forbidden_path.rs

1//! Forbidden path guard -- blocks access to sensitive filesystem paths.
2//!
3//! Denies a request when the normalized target path matches a configured
4//! forbidden glob pattern.
5
6use chio_kernel::{GuardContext, GuardDecision, KernelError};
7use glob::Pattern;
8
9use crate::action::{extract_action_checked, 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 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
74/// Error returned when an operator-supplied forbidden-path glob fails to
75/// compile. Surfaced at policy-load time so an invalid pattern rejects the
76/// policy instead of being silently dropped (which would leave the path
77/// reachable).
78#[derive(Debug, thiserror::Error)]
79pub enum ForbiddenPathConfigError {
80    #[error("invalid forbidden-path pattern {pattern:?}: {source}")]
81    InvalidPattern {
82        pattern: String,
83        source: glob::PatternError,
84    },
85    #[error("invalid forbidden-path exception {pattern:?}: {source}")]
86    InvalidException {
87        pattern: String,
88        source: glob::PatternError,
89    },
90}
91
92impl ForbiddenPathGuard {
93    pub fn new() -> Self {
94        // Default patterns are compile-time constants and always valid globs,
95        // so this construction never drops one. Operator-supplied patterns go
96        // through `with_patterns`, which rejects invalid globs rather than
97        // silently dropping them.
98        let patterns = default_forbidden_patterns()
99            .iter()
100            .filter_map(|p| Pattern::new(p).ok())
101            .collect();
102        Self {
103            patterns,
104            exceptions: Vec::new(),
105        }
106    }
107
108    /// Build a guard from operator-supplied glob patterns, failing closed.
109    ///
110    /// Any pattern or exception that is not a valid glob is rejected so a typo
111    /// in policy cannot silently disable a forbidden-path block.
112    pub fn with_patterns(
113        patterns: Vec<String>,
114        exceptions: Vec<String>,
115    ) -> Result<Self, ForbiddenPathConfigError> {
116        let patterns = patterns
117            .iter()
118            .map(|p| {
119                Pattern::new(p).map_err(|source| ForbiddenPathConfigError::InvalidPattern {
120                    pattern: p.clone(),
121                    source,
122                })
123            })
124            .collect::<Result<Vec<_>, _>>()?;
125        let exceptions = exceptions
126            .iter()
127            .map(|p| {
128                Pattern::new(p).map_err(|source| ForbiddenPathConfigError::InvalidException {
129                    pattern: p.clone(),
130                    source,
131                })
132            })
133            .collect::<Result<Vec<_>, _>>()?;
134        Ok(Self {
135            patterns,
136            exceptions,
137        })
138    }
139
140    pub fn is_forbidden(&self, path: &str) -> bool {
141        let lexical_path = normalize_path_for_policy(path);
142        let resolved_path = normalize_path_for_policy_with_fs(path);
143        let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
144        let resolved_differs_from_lexical_target = lexical_abs_path
145            .as_deref()
146            .map(|abs| abs != resolved_path.as_str())
147            .unwrap_or(resolved_path != lexical_path);
148
149        // Check exceptions first
150        for exception in &self.exceptions {
151            let lexical_matches = exception.matches(&lexical_path)
152                || lexical_abs_path
153                    .as_deref()
154                    .map(|abs| exception.matches(abs))
155                    .unwrap_or(false);
156            let resolved_matches = exception.matches(&resolved_path);
157            let exception_matches = if resolved_differs_from_lexical_target {
158                resolved_matches
159            } else {
160                resolved_matches || lexical_matches
161            };
162
163            if exception_matches {
164                return false;
165            }
166        }
167
168        // Check forbidden patterns
169        for pattern in &self.patterns {
170            if pattern.matches(&resolved_path) || pattern.matches(&lexical_path) {
171                return true;
172            }
173        }
174
175        false
176    }
177}
178
179impl Default for ForbiddenPathGuard {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl chio_kernel::Guard for ForbiddenPathGuard {
186    fn name(&self) -> &str {
187        "forbidden-path"
188    }
189
190    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
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
196        let path = match &action {
197            ToolAction::FileAccess(p) | ToolAction::FileWrite(p, _) | ToolAction::Patch(p, _) => {
198                Some(p.as_str())
199            }
200            _ => None,
201        };
202
203        let Some(path) = path else {
204            return Ok(GuardDecision::allow());
205        };
206
207        if self.is_forbidden(path) {
208            Ok(GuardDecision::deny(Vec::new()))
209        } else {
210            Ok(GuardDecision::allow())
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn blocks_ssh_keys() {
221        let guard = ForbiddenPathGuard::new();
222        assert!(guard.is_forbidden("/home/user/.ssh/id_rsa"));
223        assert!(guard.is_forbidden("/home/user/.ssh/authorized_keys"));
224    }
225
226    #[test]
227    fn blocks_etc_shadow() {
228        let guard = ForbiddenPathGuard::new();
229        assert!(guard.is_forbidden("/etc/shadow"));
230    }
231
232    #[test]
233    fn blocks_aws_credentials() {
234        let guard = ForbiddenPathGuard::new();
235        assert!(guard.is_forbidden("/home/user/.aws/credentials"));
236    }
237
238    #[test]
239    fn blocks_env_files() {
240        let guard = ForbiddenPathGuard::new();
241        assert!(guard.is_forbidden("/app/.env"));
242        assert!(guard.is_forbidden("/app/.env.local"));
243    }
244
245    #[test]
246    fn allows_normal_files() {
247        let guard = ForbiddenPathGuard::new();
248        assert!(!guard.is_forbidden("/home/user/project/src/main.rs"));
249        assert!(!guard.is_forbidden("/home/user/project/README.md"));
250        assert!(!guard.is_forbidden("/app/src/main.rs"));
251    }
252
253    #[test]
254    fn exceptions_work() {
255        let guard = ForbiddenPathGuard::with_patterns(
256            vec!["**/.env".to_string()],
257            vec!["**/project/.env".to_string()],
258        )
259        .expect("valid test patterns");
260        assert!(guard.is_forbidden("/app/.env"));
261        assert!(!guard.is_forbidden("/app/project/.env"));
262    }
263
264    #[test]
265    fn invalid_pattern_fails_closed() {
266        // A typo in an operator-supplied forbidden pattern must reject the
267        // policy, not be silently dropped (which would leave the path open).
268        let result = ForbiddenPathGuard::with_patterns(vec!["**/id_rsa[".to_string()], vec![]);
269        assert!(result.is_err());
270    }
271
272    #[test]
273    fn invalid_exception_fails_closed() {
274        let result = ForbiddenPathGuard::with_patterns(
275            vec!["**/.env".to_string()],
276            vec!["**/[".to_string()],
277        );
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn default_patterns_all_compile() {
283        // new() builds defaults via filter_map(...ok()), which would silently
284        // drop a malformed default. Guarantee none is malformed so the default
285        // guard never loses a forbidden pattern.
286        for pattern in default_forbidden_patterns() {
287            assert!(
288                Pattern::new(&pattern).is_ok(),
289                "default forbidden pattern failed to compile: {pattern}"
290            );
291        }
292    }
293
294    #[test]
295    fn windows_paths_normalized() {
296        let guard = ForbiddenPathGuard::new();
297        assert!(guard.is_forbidden(r"C:\Users\alice\.ssh\id_rsa"));
298        assert!(guard.is_forbidden(r"C:\Users\bob\.aws\credentials"));
299        assert!(!guard.is_forbidden(r"C:\Users\alice\Documents\report.docx"));
300    }
301}