microclaw_engine/internal/tool_runtime/
path_guard.rs1use std::path::{Component, Path, PathBuf};
2
3const BLOCKED_DIRS: &[&str] = &[".ssh", ".aws", ".gnupg", ".kube"];
5
6const BLOCKED_SUBPATHS: &[&[&str]] = &[&[".config", "gcloud"]];
8
9const BLOCKED_FILES: &[&str] = &[
11 ".env",
12 ".env.local",
13 ".env.production",
14 ".env.development",
15 "credentials",
16 "credentials.json",
17 "token.json",
18 "secrets.yaml",
19 "secrets.json",
20 "id_rsa",
21 "id_rsa.pub",
22 "id_ed25519",
23 "id_ed25519.pub",
24 "id_ecdsa",
25 "id_ecdsa.pub",
26 "id_dsa",
27 "id_dsa.pub",
28 ".netrc",
29 ".npmrc",
30];
31
32const BLOCKED_ABSOLUTE: &[&str] = &["/etc/shadow", "/etc/gshadow", "/etc/sudoers"];
34
35fn default_path_allowlist_path() -> Option<std::path::PathBuf> {
36 let home = std::env::var_os("HOME")
37 .map(std::path::PathBuf::from)
38 .or_else(|| std::env::var_os("USERPROFILE").map(std::path::PathBuf::from))?;
39 Some(home.join(".microclaw/sandbox-path-allowlist.txt"))
40}
41
42pub fn check_path(path: &str) -> Result<(), String> {
44 let candidate = Path::new(path);
45 if let Err(err) = validate_symlink_safety(candidate) {
46 return Err(format!(
47 "Access denied: '{path}' symlink validation failed: {err}"
48 ));
49 }
50 if let Err(err) = validate_allowlist(candidate) {
51 return Err(format!("Access denied: {err}"));
52 }
53 if is_blocked(candidate) {
54 Err(format!(
55 "Access denied: '{}' is a sensitive path and cannot be accessed.",
56 path
57 ))
58 } else {
59 Ok(())
60 }
61}
62
63fn normalize_path(path: &Path) -> PathBuf {
66 let mut parts: Vec<Component> = Vec::new();
67 for component in path.components() {
68 match component {
69 Component::CurDir => {} Component::ParentDir => {
71 if matches!(parts.last(), Some(Component::Normal(_))) {
73 parts.pop();
74 } else if matches!(parts.last(), Some(Component::RootDir)) {
75 } else {
77 parts.push(component);
78 }
79 }
80 _ => parts.push(component),
81 }
82 }
83 parts.iter().collect()
84}
85
86pub fn is_blocked(path: &Path) -> bool {
88 let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| {
93 let abs = if path.is_relative() {
94 if let Ok(cwd) = std::env::current_dir() {
95 cwd.join(path)
96 } else {
97 path.to_path_buf()
98 }
99 } else {
100 path.to_path_buf()
101 };
102 normalize_path(&abs)
103 });
104
105 let original_str = path.to_string_lossy();
107 let resolved_str = resolved.to_string_lossy();
108 for blocked in BLOCKED_ABSOLUTE {
109 if original_str == *blocked || resolved_str == *blocked {
110 return true;
111 }
112 }
113
114 let components: Vec<String> = resolved
116 .components()
117 .filter_map(|c| match c {
118 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
119 _ => None,
120 })
121 .collect();
122
123 for component in &components {
125 if BLOCKED_DIRS.contains(&component.as_str()) {
126 return true;
127 }
128 if BLOCKED_FILES.contains(&component.as_str()) {
129 return true;
130 }
131 }
132
133 for subpath in BLOCKED_SUBPATHS {
135 if subpath.len() <= components.len() {
136 for window in components.windows(subpath.len()) {
137 let matches = window
138 .iter()
139 .zip(subpath.iter())
140 .all(|(a, b)| a.as_str() == *b);
141 if matches {
142 return true;
143 }
144 }
145 }
146 }
147
148 false
149}
150
151fn validate_symlink_safety(path: &Path) -> Result<(), String> {
152 let mut cur = std::path::PathBuf::new();
153 for component in path.components() {
154 match component {
155 Component::RootDir => {
156 cur.push(Path::new("/"));
157 }
158 Component::Prefix(prefix) => {
159 cur.push(prefix.as_os_str());
160 }
161 Component::Normal(part) => {
162 cur.push(part);
163 if !cur.exists() {
164 continue;
165 }
166 let meta = std::fs::symlink_metadata(&cur).map_err(|e| {
167 format!("failed to inspect path component '{}': {e}", cur.display())
168 })?;
169 if meta.file_type().is_symlink() {
170 if cur == Path::new("/tmp") || cur == Path::new("/var") {
171 continue;
172 }
173 return Err(format!("symlink component detected at '{}'", cur.display()));
174 }
175 }
176 Component::CurDir | Component::ParentDir => {}
177 }
178 }
179 Ok(())
180}
181
182fn validate_allowlist(path: &Path) -> Result<(), String> {
183 if cfg!(test) {
184 let _ = path;
185 return Ok(());
186 }
187 let allowlist_path = std::env::var_os("MICROCLAW_PATH_ALLOWLIST")
188 .map(std::path::PathBuf::from)
189 .or_else(default_path_allowlist_path);
190 let Some(allowlist) = allowlist_path else {
191 return Ok(());
192 };
193 if !allowlist.exists() {
194 return Ok(());
195 }
196 let content = std::fs::read_to_string(&allowlist)
197 .map_err(|e| format!("failed reading allowlist '{}': {e}", allowlist.display()))?;
198 let canonical_target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
199 let mut allowed_roots = Vec::new();
200 for raw in content.lines() {
201 let line = raw.trim();
202 if line.is_empty() || line.starts_with('#') {
203 continue;
204 }
205 let candidate = Path::new(line);
206 let canonical =
207 std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf());
208 allowed_roots.push(canonical);
209 }
210 if allowed_roots.is_empty() {
211 return Ok(());
213 }
214 if allowed_roots
215 .iter()
216 .any(|root| canonical_target.starts_with(root))
217 {
218 Ok(())
219 } else {
220 Err(format!(
221 "'{}' is outside configured allowlist '{}'",
222 canonical_target.display(),
223 allowlist.display()
224 ))
225 }
226}
227
228pub fn filter_paths(paths: Vec<String>) -> Vec<String> {
230 paths
231 .into_iter()
232 .filter(|p| !is_blocked(Path::new(p)))
233 .collect()
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_blocks_ssh_directory() {
242 assert!(is_blocked(Path::new("/home/user/.ssh/id_rsa")));
243 assert!(is_blocked(Path::new("/home/user/.ssh/config")));
244 }
245
246 #[test]
247 fn test_blocks_aws_directory() {
248 assert!(is_blocked(Path::new("/home/user/.aws/credentials")));
249 }
250
251 #[test]
252 fn test_blocks_gnupg_directory() {
253 assert!(is_blocked(Path::new("/home/user/.gnupg/private-keys-v1.d")));
254 }
255
256 #[test]
257 fn test_blocks_kube_directory() {
258 assert!(is_blocked(Path::new("/home/user/.kube/config")));
259 }
260
261 #[test]
262 fn test_blocks_gcloud_config() {
263 assert!(is_blocked(Path::new(
264 "/home/user/.config/gcloud/credentials.db"
265 )));
266 }
267
268 #[test]
269 fn test_blocks_env_files() {
270 assert!(is_blocked(Path::new("/project/.env")));
271 assert!(is_blocked(Path::new("/project/.env.local")));
272 assert!(is_blocked(Path::new("/project/.env.production")));
273 assert!(is_blocked(Path::new("/project/.env.development")));
274 }
275
276 #[test]
277 fn test_blocks_credential_files() {
278 assert!(is_blocked(Path::new("/project/credentials.json")));
279 assert!(is_blocked(Path::new("/project/token.json")));
280 assert!(is_blocked(Path::new("/project/secrets.yaml")));
281 assert!(is_blocked(Path::new("/project/secrets.json")));
282 }
283
284 #[test]
285 fn test_blocks_ssh_keys() {
286 assert!(is_blocked(Path::new("/home/user/id_rsa")));
287 assert!(is_blocked(Path::new("/home/user/id_rsa.pub")));
288 assert!(is_blocked(Path::new("/home/user/id_ed25519")));
289 assert!(is_blocked(Path::new("/home/user/id_ed25519.pub")));
290 }
291
292 #[test]
293 fn test_blocks_netrc_npmrc() {
294 assert!(is_blocked(Path::new("/home/user/.netrc")));
295 assert!(is_blocked(Path::new("/home/user/.npmrc")));
296 }
297
298 #[test]
299 fn test_blocks_etc_shadow() {
300 assert!(is_blocked(Path::new("/etc/shadow")));
301 assert!(is_blocked(Path::new("/etc/gshadow")));
302 assert!(is_blocked(Path::new("/etc/sudoers")));
303 }
304
305 #[test]
306 fn test_allows_normal_files() {
307 assert!(!is_blocked(Path::new("/home/user/project/main.rs")));
308 assert!(!is_blocked(Path::new("/tmp/test.txt")));
309 assert!(!is_blocked(Path::new("src/config.rs")));
310 }
311
312 #[test]
313 fn test_check_path_ok() {
314 assert!(check_path("src/main.rs").is_ok());
315 }
316
317 #[test]
318 fn test_check_path_blocked() {
319 let result = check_path("/home/user/.ssh/id_rsa");
320 assert!(result.is_err());
321 assert!(result.unwrap_err().contains("Access denied"));
322 }
323
324 #[test]
325 fn test_blocks_traversal_via_parent_dir() {
326 #[cfg(not(target_os = "windows"))]
328 {
329 assert!(is_blocked(Path::new("/tmp/../etc/shadow")));
330 assert!(is_blocked(Path::new(
331 "/home/user/project/../../.ssh/id_rsa"
332 )));
333 assert!(is_blocked(Path::new("/foo/bar/../../../etc/sudoers")));
334 assert!(is_blocked(Path::new("/tmp/../home/user/.aws/credentials")));
335 }
336
337 #[cfg(target_os = "windows")]
338 {
339 assert!(is_blocked(Path::new(r"C:\temp\..\Users\user\.ssh\id_rsa")));
340 assert!(is_blocked(Path::new(
341 r"C:\project\src\..\..\.aws\credentials"
342 )));
343 }
344 }
345
346 #[test]
347 fn test_normalize_path() {
348 assert_eq!(
349 normalize_path(Path::new("/tmp/../etc/shadow")),
350 PathBuf::from("/etc/shadow")
351 );
352 assert_eq!(
353 normalize_path(Path::new("/a/b/../c")),
354 PathBuf::from("/a/c")
355 );
356 assert_eq!(
357 normalize_path(Path::new("/a/./b/./c")),
358 PathBuf::from("/a/b/c")
359 );
360 assert_eq!(normalize_path(Path::new("a/b/../c")), PathBuf::from("a/c"));
361 }
362
363 #[test]
364 fn test_filter_paths() {
365 let paths = vec![
366 "src/main.rs".to_string(),
367 "/home/user/.ssh/id_rsa".to_string(),
368 "README.md".to_string(),
369 "/project/.env".to_string(),
370 ];
371 let filtered = filter_paths(paths);
372 assert_eq!(filtered.len(), 2);
373 assert_eq!(filtered[0], "src/main.rs");
374 assert_eq!(filtered[1], "README.md");
375 }
376
377 #[test]
378 fn test_symlink_rejected() {
379 let dir = std::env::temp_dir().join(format!("mc_pg_{}", uuid::Uuid::new_v4()));
380 std::fs::create_dir_all(&dir).unwrap();
381 let target = dir.join("target.txt");
382 std::fs::write(&target, "ok").unwrap();
383 let link = dir.join("link.txt");
384 #[cfg(unix)]
385 std::os::unix::fs::symlink(&target, &link).unwrap();
386 #[cfg(windows)]
387 if let Err(error) = std::os::windows::fs::symlink_file(&target, &link) {
388 if error.raw_os_error() == Some(1314) {
392 let _ = std::fs::remove_dir_all(&dir);
393 return;
394 }
395 panic!("failed to create test symlink: {error}");
396 }
397 let err = check_path(link.to_string_lossy().as_ref()).unwrap_err();
398 assert!(err.contains("symlink"));
399 let _ = std::fs::remove_dir_all(&dir);
400 }
401}