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/// The path allowlist file in effect (`MICROCLAW_PATH_ALLOWLIST` or the
43/// default under `~/.microclaw`), whether or not it exists.
44pub fn path_allowlist_file() -> Option<PathBuf> {
45    std::env::var_os("MICROCLAW_PATH_ALLOWLIST")
46        .map(PathBuf::from)
47        .or_else(default_path_allowlist_path)
48}
49
50/// File names that make up the agent's own prompt-injected governance layer
51/// (memory, personality, user model) when they live in the data directory.
52const GOVERNANCE_FILE_NAMES: &[&str] = &["AGENTS.md", "SOUL.md", "USER.md"];
53
54/// Write guard for MicroClaw's own governance files: the config file, the
55/// AGENTS.md / SOUL.md / USER.md files under the data directory, soul and
56/// project-context directories, and the path allowlist. These are injected
57/// into other chats' prompts or define access policy, so the generic file
58/// tools must not rewrite them; scoped tools (`write_memory`) keep their own
59/// permission checks. An empty guard allows everything.
60#[derive(Clone, Debug, Default)]
61pub struct GovernanceGuard {
62    protected_files: Vec<PathBuf>,
63    protected_dirs: Vec<PathBuf>,
64    /// Roots under which governance file names are protected by name.
65    data_roots: Vec<PathBuf>,
66    /// Roots exempt from the by-name rule (the agent's working directory,
67    /// where a project may legitimately carry its own AGENTS.md).
68    exempt_roots: Vec<PathBuf>,
69}
70
71impl GovernanceGuard {
72    pub fn protect_file(mut self, path: impl AsRef<Path>) -> Self {
73        self.protected_files
74            .push(resolve_for_compare(path.as_ref()));
75        self
76    }
77
78    pub fn protect_dir(mut self, path: impl AsRef<Path>) -> Self {
79        self.protected_dirs.push(resolve_for_compare(path.as_ref()));
80        self
81    }
82
83    pub fn protect_data_root(mut self, path: impl AsRef<Path>) -> Self {
84        self.data_roots.push(resolve_for_compare(path.as_ref()));
85        self
86    }
87
88    pub fn exempt(mut self, path: impl AsRef<Path>) -> Self {
89        self.exempt_roots.push(resolve_for_compare(path.as_ref()));
90        self
91    }
92
93    /// Returns Err(message) when `path` is a protected governance path.
94    pub fn check_write(&self, path: &Path) -> Result<(), String> {
95        let target = resolve_for_compare(path);
96        if self.is_protected(&target) {
97            Err(format!(
98                "Access denied: '{}' is a MicroClaw governance file (config, memory, soul or \
99                 context) and cannot be modified with file tools. Use write_memory for memory; \
100                 the operator manages the rest.",
101                path.display()
102            ))
103        } else {
104            Ok(())
105        }
106    }
107
108    fn is_protected(&self, target: &Path) -> bool {
109        if self.protected_files.iter().any(|f| paths_equal(target, f)) {
110            return true;
111        }
112        if self
113            .protected_dirs
114            .iter()
115            .any(|d| path_starts_with(target, d))
116        {
117            return true;
118        }
119        if self
120            .exempt_roots
121            .iter()
122            .any(|r| path_starts_with(target, r))
123        {
124            return false;
125        }
126        let Some(root) = self
127            .data_roots
128            .iter()
129            .find(|root| path_starts_with(target, root))
130        else {
131            return false;
132        };
133        let file_name = target
134            .file_name()
135            .map(|n| n.to_string_lossy().to_string())
136            .unwrap_or_default();
137        if GOVERNANCE_FILE_NAMES
138            .iter()
139            .any(|name| file_name.eq_ignore_ascii_case(name))
140        {
141            return true;
142        }
143        // Chat-scoped project context: <data>/.../groups/<channel>/<chat>/context/**
144        let relative: Vec<String> = target
145            .strip_prefix(root)
146            .map(|rel| {
147                rel.components()
148                    .filter_map(|c| match c {
149                        Component::Normal(s) => Some(s.to_string_lossy().to_lowercase()),
150                        _ => None,
151                    })
152                    .collect()
153            })
154            .unwrap_or_default();
155        relative
156            .iter()
157            .position(|c| c == "groups")
158            .is_some_and(|i| relative[i + 1..].iter().any(|c| c == "context"))
159    }
160}
161
162/// Resolve a path for comparison: canonicalize the longest existing ancestor
163/// (so `/var` vs `/private/var` and `..` tricks cannot slip past) and append
164/// the not-yet-existing remainder.
165fn resolve_for_compare(path: &Path) -> PathBuf {
166    let abs = if path.is_relative() {
167        std::env::current_dir()
168            .map(|cwd| cwd.join(path))
169            .unwrap_or_else(|_| path.to_path_buf())
170    } else {
171        path.to_path_buf()
172    };
173    let abs = normalize_path(&abs);
174    let mut existing = abs.as_path();
175    let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
176    loop {
177        if let Ok(canonical) = std::fs::canonicalize(existing) {
178            let mut out = canonical;
179            for part in rest.iter().rev() {
180                out.push(part);
181            }
182            return out;
183        }
184        match (existing.parent(), existing.file_name()) {
185            (Some(parent), Some(name)) => {
186                rest.push(name);
187                existing = parent;
188            }
189            _ => return abs,
190        }
191    }
192}
193
194fn case_insensitive_fs() -> bool {
195    cfg!(any(target_os = "macos", target_os = "windows"))
196}
197
198fn paths_equal(a: &Path, b: &Path) -> bool {
199    if case_insensitive_fs() {
200        a.to_string_lossy().to_lowercase() == b.to_string_lossy().to_lowercase()
201    } else {
202        a == b
203    }
204}
205
206fn path_starts_with(path: &Path, root: &Path) -> bool {
207    if case_insensitive_fs() {
208        let lower = |p: &Path| PathBuf::from(p.to_string_lossy().to_lowercase());
209        lower(path).starts_with(lower(root))
210    } else {
211        path.starts_with(root)
212    }
213}
214
215/// Check if a path is blocked. Returns Err(message) if blocked.
216pub fn check_path(path: &str) -> Result<(), String> {
217    let candidate = Path::new(path);
218    if let Err(err) = validate_symlink_safety(candidate) {
219        return Err(format!(
220            "Access denied: '{path}' symlink validation failed: {err}"
221        ));
222    }
223    if let Err(err) = validate_allowlist(candidate) {
224        return Err(format!("Access denied: {err}"));
225    }
226    if is_blocked(candidate) {
227        Err(format!(
228            "Access denied: '{}' is a sensitive path and cannot be accessed.",
229            path
230        ))
231    } else {
232        Ok(())
233    }
234}
235
236/// Logically normalize a path by resolving `.` and `..` components without
237/// requiring the path to exist on the filesystem.
238fn normalize_path(path: &Path) -> PathBuf {
239    let mut parts: Vec<Component> = Vec::new();
240    for component in path.components() {
241        match component {
242            Component::CurDir => {} // skip "."
243            Component::ParentDir => {
244                // Pop the last Normal component if possible
245                if matches!(parts.last(), Some(Component::Normal(_))) {
246                    parts.pop();
247                } else if matches!(parts.last(), Some(Component::RootDir)) {
248                    // Already at root; `..` above root stays at root
249                } else {
250                    parts.push(component);
251                }
252            }
253            _ => parts.push(component),
254        }
255    }
256    parts.iter().collect()
257}
258
259/// Check if a file path should be blocked.
260pub fn is_blocked(path: &Path) -> bool {
261    // Try to resolve symlinks; if the file doesn't exist, fall back to
262    // logical normalization so that `..` components are still resolved.
263    // For relative paths, prepend the working directory first so `..`
264    // at the start can be resolved against the absolute prefix.
265    let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| {
266        let abs = if path.is_relative() {
267            if let Ok(cwd) = std::env::current_dir() {
268                cwd.join(path)
269            } else {
270                path.to_path_buf()
271            }
272        } else {
273            path.to_path_buf()
274        };
275        normalize_path(&abs)
276    });
277
278    // Check against blocked absolute paths (both original and resolved)
279    let original_str = path.to_string_lossy();
280    let resolved_str = resolved.to_string_lossy();
281    for blocked in BLOCKED_ABSOLUTE {
282        if original_str == *blocked || resolved_str == *blocked {
283            return true;
284        }
285    }
286
287    // Collect components as strings for checking
288    let components: Vec<String> = resolved
289        .components()
290        .filter_map(|c| match c {
291            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
292            _ => None,
293        })
294        .collect();
295
296    // Check each component against blocked dirs and files
297    for component in &components {
298        if BLOCKED_DIRS.contains(&component.as_str()) {
299            return true;
300        }
301        if BLOCKED_FILES.contains(&component.as_str()) {
302            return true;
303        }
304    }
305
306    // Check blocked subpaths (consecutive components)
307    for subpath in BLOCKED_SUBPATHS {
308        if subpath.len() <= components.len() {
309            for window in components.windows(subpath.len()) {
310                let matches = window
311                    .iter()
312                    .zip(subpath.iter())
313                    .all(|(a, b)| a.as_str() == *b);
314                if matches {
315                    return true;
316                }
317            }
318        }
319    }
320
321    false
322}
323
324fn validate_symlink_safety(path: &Path) -> Result<(), String> {
325    let mut cur = std::path::PathBuf::new();
326    for component in path.components() {
327        match component {
328            Component::RootDir => {
329                cur.push(Path::new("/"));
330            }
331            Component::Prefix(prefix) => {
332                cur.push(prefix.as_os_str());
333            }
334            Component::Normal(part) => {
335                cur.push(part);
336                if !cur.exists() {
337                    continue;
338                }
339                let meta = std::fs::symlink_metadata(&cur).map_err(|e| {
340                    format!("failed to inspect path component '{}': {e}", cur.display())
341                })?;
342                if meta.file_type().is_symlink() {
343                    if cur == Path::new("/tmp") || cur == Path::new("/var") {
344                        continue;
345                    }
346                    return Err(format!("symlink component detected at '{}'", cur.display()));
347                }
348            }
349            Component::CurDir | Component::ParentDir => {}
350        }
351    }
352    Ok(())
353}
354
355fn validate_allowlist(path: &Path) -> Result<(), String> {
356    if cfg!(test) {
357        let _ = path;
358        return Ok(());
359    }
360    let Some(allowlist) = path_allowlist_file() else {
361        return Ok(());
362    };
363    if !allowlist.exists() {
364        return Ok(());
365    }
366    let content = std::fs::read_to_string(&allowlist)
367        .map_err(|e| format!("failed reading allowlist '{}': {e}", allowlist.display()))?;
368    let canonical_target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
369    let mut allowed_roots = Vec::new();
370    for raw in content.lines() {
371        let line = raw.trim();
372        if line.is_empty() || line.starts_with('#') {
373            continue;
374        }
375        let candidate = Path::new(line);
376        let canonical =
377            std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf());
378        allowed_roots.push(canonical);
379    }
380    if allowed_roots.is_empty() {
381        // Keep compatibility: treat empty allowlist as disabled.
382        return Ok(());
383    }
384    if allowed_roots
385        .iter()
386        .any(|root| canonical_target.starts_with(root))
387    {
388        Ok(())
389    } else {
390        Err(format!(
391            "'{}' is outside configured allowlist '{}'",
392            canonical_target.display(),
393            allowlist.display()
394        ))
395    }
396}
397
398/// Filter a list of paths, removing blocked ones. For glob results.
399pub fn filter_paths(paths: Vec<String>) -> Vec<String> {
400    paths
401        .into_iter()
402        .filter(|p| !is_blocked(Path::new(p)))
403        .collect()
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn governance_fixture() -> (tempfile::TempDir, GovernanceGuard) {
411        let dir = tempfile::tempdir().unwrap();
412        let root = dir.path();
413        std::fs::create_dir_all(root.join("working_dir")).unwrap();
414        std::fs::create_dir_all(root.join("runtime/groups/telegram/42")).unwrap();
415        std::fs::write(root.join("microclaw.config.yaml"), "x").unwrap();
416        let guard = GovernanceGuard::default()
417            .protect_data_root(root)
418            .exempt(root.join("working_dir"))
419            .exempt(root.join("skills"))
420            .protect_dir(root.join("souls"))
421            .protect_dir(root.join("context"))
422            .protect_file(root.join("microclaw.config.yaml"));
423        (dir, guard)
424    }
425
426    #[test]
427    fn governance_guard_blocks_memory_soul_and_user_files() {
428        let (dir, guard) = governance_fixture();
429        let root = dir.path();
430        for rel in [
431            "runtime/groups/AGENTS.md",
432            "runtime/groups/telegram/AGENTS.md",
433            "runtime/groups/telegram/42/AGENTS.md",
434            "runtime/groups/telegram/42/USER.md",
435            "runtime/groups/42/SOUL.md",
436            "SOUL.md",
437            "runtime/groups/telegram/42/context/notes.md",
438            "souls/house.md",
439            "context/project.md",
440            "microclaw.config.yaml",
441        ] {
442            assert!(
443                guard.check_write(&root.join(rel)).is_err(),
444                "{rel} should be protected"
445            );
446        }
447    }
448
449    #[test]
450    fn governance_guard_resolves_dot_dot_and_case() {
451        let (dir, guard) = governance_fixture();
452        let sneaky = dir
453            .path()
454            .join("working_dir/../runtime/groups/telegram/42/AGENTS.md");
455        assert!(guard.check_write(&sneaky).is_err());
456        let lower = dir.path().join("runtime/groups/agents.md");
457        assert!(guard.check_write(&lower).is_err());
458    }
459
460    #[test]
461    fn governance_guard_allows_working_dir_and_skills() {
462        let (dir, guard) = governance_fixture();
463        let root = dir.path();
464        for rel in [
465            "working_dir/repo/AGENTS.md",
466            "working_dir/notes.txt",
467            "skills/foo/SKILL.md",
468            "runtime/exports/report.md",
469        ] {
470            assert!(guard.check_write(&root.join(rel)).is_ok(), "{rel}");
471        }
472        assert!(GovernanceGuard::default()
473            .check_write(&root.join("SOUL.md"))
474            .is_ok());
475    }
476
477    #[test]
478    fn test_blocks_ssh_directory() {
479        assert!(is_blocked(Path::new("/home/user/.ssh/id_rsa")));
480        assert!(is_blocked(Path::new("/home/user/.ssh/config")));
481    }
482
483    #[test]
484    fn test_blocks_aws_directory() {
485        assert!(is_blocked(Path::new("/home/user/.aws/credentials")));
486    }
487
488    #[test]
489    fn test_blocks_gnupg_directory() {
490        assert!(is_blocked(Path::new("/home/user/.gnupg/private-keys-v1.d")));
491    }
492
493    #[test]
494    fn test_blocks_kube_directory() {
495        assert!(is_blocked(Path::new("/home/user/.kube/config")));
496    }
497
498    #[test]
499    fn test_blocks_gcloud_config() {
500        assert!(is_blocked(Path::new(
501            "/home/user/.config/gcloud/credentials.db"
502        )));
503    }
504
505    #[test]
506    fn test_blocks_env_files() {
507        assert!(is_blocked(Path::new("/project/.env")));
508        assert!(is_blocked(Path::new("/project/.env.local")));
509        assert!(is_blocked(Path::new("/project/.env.production")));
510        assert!(is_blocked(Path::new("/project/.env.development")));
511    }
512
513    #[test]
514    fn test_blocks_credential_files() {
515        assert!(is_blocked(Path::new("/project/credentials.json")));
516        assert!(is_blocked(Path::new("/project/token.json")));
517        assert!(is_blocked(Path::new("/project/secrets.yaml")));
518        assert!(is_blocked(Path::new("/project/secrets.json")));
519    }
520
521    #[test]
522    fn test_blocks_ssh_keys() {
523        assert!(is_blocked(Path::new("/home/user/id_rsa")));
524        assert!(is_blocked(Path::new("/home/user/id_rsa.pub")));
525        assert!(is_blocked(Path::new("/home/user/id_ed25519")));
526        assert!(is_blocked(Path::new("/home/user/id_ed25519.pub")));
527    }
528
529    #[test]
530    fn test_blocks_netrc_npmrc() {
531        assert!(is_blocked(Path::new("/home/user/.netrc")));
532        assert!(is_blocked(Path::new("/home/user/.npmrc")));
533    }
534
535    #[test]
536    fn test_blocks_etc_shadow() {
537        assert!(is_blocked(Path::new("/etc/shadow")));
538        assert!(is_blocked(Path::new("/etc/gshadow")));
539        assert!(is_blocked(Path::new("/etc/sudoers")));
540    }
541
542    #[test]
543    fn test_allows_normal_files() {
544        assert!(!is_blocked(Path::new("/home/user/project/main.rs")));
545        assert!(!is_blocked(Path::new("/tmp/test.txt")));
546        assert!(!is_blocked(Path::new("src/config.rs")));
547    }
548
549    #[test]
550    fn test_check_path_ok() {
551        assert!(check_path("src/main.rs").is_ok());
552    }
553
554    #[test]
555    fn test_check_path_blocked() {
556        let result = check_path("/home/user/.ssh/id_rsa");
557        assert!(result.is_err());
558        assert!(result.unwrap_err().contains("Access denied"));
559    }
560
561    #[test]
562    fn test_blocks_traversal_via_parent_dir() {
563        // Paths using .. to reach blocked locations should still be caught
564        #[cfg(not(target_os = "windows"))]
565        {
566            assert!(is_blocked(Path::new("/tmp/../etc/shadow")));
567            assert!(is_blocked(Path::new(
568                "/home/user/project/../../.ssh/id_rsa"
569            )));
570            assert!(is_blocked(Path::new("/foo/bar/../../../etc/sudoers")));
571            assert!(is_blocked(Path::new("/tmp/../home/user/.aws/credentials")));
572        }
573
574        #[cfg(target_os = "windows")]
575        {
576            assert!(is_blocked(Path::new(r"C:\temp\..\Users\user\.ssh\id_rsa")));
577            assert!(is_blocked(Path::new(
578                r"C:\project\src\..\..\.aws\credentials"
579            )));
580        }
581    }
582
583    #[test]
584    fn test_normalize_path() {
585        assert_eq!(
586            normalize_path(Path::new("/tmp/../etc/shadow")),
587            PathBuf::from("/etc/shadow")
588        );
589        assert_eq!(
590            normalize_path(Path::new("/a/b/../c")),
591            PathBuf::from("/a/c")
592        );
593        assert_eq!(
594            normalize_path(Path::new("/a/./b/./c")),
595            PathBuf::from("/a/b/c")
596        );
597        assert_eq!(normalize_path(Path::new("a/b/../c")), PathBuf::from("a/c"));
598    }
599
600    #[test]
601    fn test_filter_paths() {
602        let paths = vec![
603            "src/main.rs".to_string(),
604            "/home/user/.ssh/id_rsa".to_string(),
605            "README.md".to_string(),
606            "/project/.env".to_string(),
607        ];
608        let filtered = filter_paths(paths);
609        assert_eq!(filtered.len(), 2);
610        assert_eq!(filtered[0], "src/main.rs");
611        assert_eq!(filtered[1], "README.md");
612    }
613
614    #[test]
615    fn test_symlink_rejected() {
616        let dir = std::env::temp_dir().join(format!("mc_pg_{}", uuid::Uuid::new_v4()));
617        std::fs::create_dir_all(&dir).unwrap();
618        let target = dir.join("target.txt");
619        std::fs::write(&target, "ok").unwrap();
620        let link = dir.join("link.txt");
621        #[cfg(unix)]
622        std::os::unix::fs::symlink(&target, &link).unwrap();
623        #[cfg(windows)]
624        if let Err(error) = std::os::windows::fs::symlink_file(&target, &link) {
625            // Creating symlinks on Windows requires Developer Mode or the
626            // SeCreateSymbolicLink privilege. The behavior is covered when
627            // the host permits constructing the fixture.
628            if error.raw_os_error() == Some(1314) {
629                let _ = std::fs::remove_dir_all(&dir);
630                return;
631            }
632            panic!("failed to create test symlink: {error}");
633        }
634        let err = check_path(link.to_string_lossy().as_ref()).unwrap_err();
635        assert!(err.contains("symlink"));
636        let _ = std::fs::remove_dir_all(&dir);
637    }
638}