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 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
50const GOVERNANCE_FILE_NAMES: &[&str] = &["AGENTS.md", "SOUL.md", "USER.md"];
53
54#[derive(Clone, Debug, Default)]
61pub struct GovernanceGuard {
62 protected_files: Vec<PathBuf>,
63 protected_dirs: Vec<PathBuf>,
64 data_roots: Vec<PathBuf>,
66 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 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 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
162fn 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
215pub 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
236fn 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 => {} Component::ParentDir => {
244 if matches!(parts.last(), Some(Component::Normal(_))) {
246 parts.pop();
247 } else if matches!(parts.last(), Some(Component::RootDir)) {
248 } else {
250 parts.push(component);
251 }
252 }
253 _ => parts.push(component),
254 }
255 }
256 parts.iter().collect()
257}
258
259pub fn is_blocked(path: &Path) -> bool {
261 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 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 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 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 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 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
398pub 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 #[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 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}