chio_guards/
forbidden_path.rs1use 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/**".to_string(),
19 "**/id_rsa*".to_string(),
20 "**/id_ed25519*".to_string(),
21 "**/id_ecdsa*".to_string(),
22 "**/.aws/**".to_string(),
24 "**/.env".to_string(),
26 "**/.env.*".to_string(),
27 "**/.git-credentials".to_string(),
29 "**/.gitconfig".to_string(),
30 "**/.gnupg/**".to_string(),
32 "**/.kube/**".to_string(),
34 "**/.docker/**".to_string(),
36 "**/.npmrc".to_string(),
38 "**/.password-store/**".to_string(),
40 "**/pass/**".to_string(),
41 "**/.1password/**".to_string(),
43 "/etc/shadow".to_string(),
45 "/etc/passwd".to_string(),
46 "/etc/sudoers".to_string(),
47 ];
48
49 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
68pub struct ForbiddenPathGuard {
70 patterns: Vec<Pattern>,
71 exceptions: Vec<Pattern>,
72}
73
74#[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 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 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 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 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 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 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}