Skip to main content

microclaw_engine/internal/tool_runtime/
path_guard.rs

1use std::path::{Component, Path, PathBuf};
2
3/// Directory components that are always blocked.
4const BLOCKED_DIRS: &[&str] = &[".ssh", ".aws", ".gnupg", ".kube"];
5
6/// Blocked subpath (checked as consecutive components).
7const BLOCKED_SUBPATHS: &[&[&str]] = &[&[".config", "gcloud"]];
8
9/// File names that are always blocked (exact component match).
10const 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
32/// Absolute paths that are always blocked.
33const 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
42/// Check if a path is blocked. Returns Err(message) if blocked.
43pub 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
63/// Logically normalize a path by resolving `.` and `..` components without
64/// requiring the path to exist on the filesystem.
65fn 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 => {} // skip "."
70            Component::ParentDir => {
71                // Pop the last Normal component if possible
72                if matches!(parts.last(), Some(Component::Normal(_))) {
73                    parts.pop();
74                } else if matches!(parts.last(), Some(Component::RootDir)) {
75                    // Already at root; `..` above root stays at root
76                } else {
77                    parts.push(component);
78                }
79            }
80            _ => parts.push(component),
81        }
82    }
83    parts.iter().collect()
84}
85
86/// Check if a file path should be blocked.
87pub fn is_blocked(path: &Path) -> bool {
88    // Try to resolve symlinks; if the file doesn't exist, fall back to
89    // logical normalization so that `..` components are still resolved.
90    // For relative paths, prepend the working directory first so `..`
91    // at the start can be resolved against the absolute prefix.
92    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    // Check against blocked absolute paths (both original and resolved)
106    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    // Collect components as strings for checking
115    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    // Check each component against blocked dirs and files
124    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    // Check blocked subpaths (consecutive components)
134    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        // Keep compatibility: treat empty allowlist as disabled.
212        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
228/// Filter a list of paths, removing blocked ones. For glob results.
229pub 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        // Paths using .. to reach blocked locations should still be caught
327        #[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            // Creating symlinks on Windows requires Developer Mode or the
389            // SeCreateSymbolicLink privilege. The behavior is covered when
390            // the host permits constructing the fixture.
391            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}