chio_guards/
forbidden_path.rs1use 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/**".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
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 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 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}