1use anyhow::{bail, Context, Result};
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::ffi::{OsStr, OsString};
6use std::path::{Path, PathBuf};
7
8const MAX_WORKSPACE_SCAN_ENTRIES: usize = 1_000_000;
9const MAX_WORKSPACE_SCAN_DEPTH: usize = 64;
10const MAX_CREDENTIAL_ALIAS_SCAN_ENTRIES: usize = 5_000_000;
14
15pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
18 ".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
19];
20
21pub const PROTECTED_WORKSPACE_FILES: &[&str] = &[
24 ".gitmodules",
25 ".mcp.json",
26 ".ripgreprc",
27 ".bashrc",
28 ".bash_profile",
29 ".zshrc",
30 ".zprofile",
31 ".profile",
32];
33
34pub fn is_protected_workspace_path(path: &str) -> bool {
37 let normalized = path.replace('\\', "/");
38 let mut components = normalized
39 .split('/')
40 .filter(|component| !component.is_empty() && *component != ".");
41 let Some(first) = components.next() else {
42 return false;
43 };
44 if first == ".." || components.clone().any(|component| component == "..") {
45 return false;
46 }
47
48 PROTECTED_WORKSPACE_DIRECTORIES
49 .iter()
50 .any(|protected| first.eq_ignore_ascii_case(protected))
51 || PROTECTED_WORKSPACE_FILES
52 .iter()
53 .any(|protected| first.eq_ignore_ascii_case(protected))
54}
55
56#[derive(Debug)]
57pub(super) struct SandboxPolicy {
58 pub(super) workspace: PathBuf,
59 pub(super) scratch: PathBuf,
60 pub(super) allow_read: Vec<PathBuf>,
61 pub(super) deny_read: Vec<PathBuf>,
62 pub(super) allow_write: Vec<PathBuf>,
63 pub(super) deny_write: Vec<PathBuf>,
64}
65
66impl SandboxPolicy {
67 pub(super) fn for_execution(workspace: &Path, scratch: &Path) -> Result<Self> {
68 let workspace = workspace
69 .canonicalize()
70 .context("failed to resolve the native sandbox workspace")?;
71 let scratch = scratch
72 .canonicalize()
73 .context("failed to resolve the native sandbox scratch directory")?;
74
75 let mut protected = protected_workspace_paths(&workspace)?;
76 if let Some(git_dir) = resolved_git_dir(&workspace) {
77 protected.push(git_dir);
78 }
79 expand_existing_canonical_paths(&mut protected);
80
81 let mut sensitive = sensitive_paths();
82 let scan = scan_workspace_security(&workspace)?;
83 sensitive.extend(fixed_workspace_secret_paths(&workspace));
84 sensitive.extend(scan.nested_env);
85 sensitive.extend(scan.source_hardlinks);
86 sensitive.extend(workspace_credential_hardlink_aliases(
87 &workspace, &sensitive,
88 )?);
89 expand_existing_canonical_paths(&mut sensitive);
90
91 let mut deny_read = sensitive.clone();
92 deny_read.extend(read_denied_roots());
93 let mut allow_read = readable_tool_paths(&workspace, &scratch);
94 let allow_write = vec![workspace.clone(), scratch.clone()];
95 let mut deny_write = protected;
96 deny_write.extend(sensitive);
97 validate_denied_workspace_entries(&workspace, &deny_write)?;
98
99 deduplicate_paths(&mut allow_read);
100 deduplicate_paths(&mut deny_read);
101 remove_redundant_descendants(&mut deny_write);
102
103 Ok(Self {
104 workspace,
105 scratch,
106 allow_read,
107 deny_read,
108 allow_write,
109 deny_write,
110 })
111 }
112
113 pub(super) fn child_environment(
114 &self,
115 explicit: Option<&HashMap<String, String>>,
116 ) -> Result<BTreeMap<OsString, OsString>> {
117 compose_child_env(explicit, &self.scratch)
118 }
119}
120
121#[cfg(any(target_os = "linux", windows))]
122pub(super) fn requires_directory_placeholder(workspace: &Path, path: &Path) -> bool {
123 let Ok(relative) = path.strip_prefix(workspace) else {
124 return false;
125 };
126 let mut components = relative.components();
127 let Some(component) = components.next() else {
128 return false;
129 };
130 if components.next().is_some() {
131 return false;
132 }
133 let name = component.as_os_str().to_string_lossy();
134 PROTECTED_WORKSPACE_DIRECTORIES
135 .iter()
136 .any(|protected| name.eq_ignore_ascii_case(protected))
137}
138
139fn validate_denied_workspace_entries(workspace: &Path, paths: &[PathBuf]) -> Result<()> {
140 for path in paths.iter().filter(|path| path.starts_with(workspace)) {
141 match std::fs::symlink_metadata(path) {
142 Ok(metadata) if metadata.file_type().is_symlink() => {
143 bail!(
144 "native sandbox refuses a symbolic link at protected workspace path {}",
145 path.display()
146 );
147 }
148 Ok(_) => {}
149 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
150 Err(error) => {
151 return Err(error).with_context(|| {
152 format!(
153 "failed to inspect protected workspace path {}",
154 path.display()
155 )
156 });
157 }
158 }
159 }
160 Ok(())
161}
162
163fn compose_child_env(
164 explicit: Option<&HashMap<String, String>>,
165 scratch: &Path,
166) -> Result<BTreeMap<OsString, OsString>> {
167 const SAFE_KEYS: &[&str] = &[
168 "PATH",
169 "USER",
170 "USERNAME",
171 "LOGNAME",
172 "SHELL",
173 "LANG",
174 "LC_ALL",
175 "LC_CTYPE",
176 "TZ",
177 "TERM",
178 "COLORTERM",
179 "NO_COLOR",
180 "CI",
181 "CARGO_HOME",
182 "RUSTUP_HOME",
183 "RUSTC_WRAPPER",
184 "GOPATH",
185 "GOROOT",
186 "GOMODCACHE",
187 "NVM_DIR",
188 "FNM_DIR",
189 "VOLTA_HOME",
190 "BUN_INSTALL",
191 "DENO_DIR",
192 "PNPM_HOME",
193 "JAVA_HOME",
194 "GRADLE_USER_HOME",
195 "MAVEN_HOME",
196 "SDKROOT",
197 "DEVELOPER_DIR",
198 "PKG_CONFIG_PATH",
199 "LIBRARY_PATH",
200 "CPATH",
201 "CC",
202 "CXX",
203 "AR",
204 "SYSTEMROOT",
205 "SYSTEMDRIVE",
206 "WINDIR",
207 "COMSPEC",
208 "PATHEXT",
209 "PSMODULEPATH",
210 "PROGRAMDATA",
211 "PROGRAMFILES",
212 "PROGRAMFILES(X86)",
213 "PROGRAMW6432",
214 "COMMONPROGRAMFILES",
215 "COMMONPROGRAMFILES(X86)",
216 "COMMONPROGRAMW6432",
217 "PROCESSOR_ARCHITECTURE",
218 "NUMBER_OF_PROCESSORS",
219 "OS",
220 "HOMEDRIVE",
221 "HOMEPATH",
222 "PUBLIC",
223 "ALLUSERSPROFILE",
224 ];
225
226 let mut environment = BTreeMap::new();
227 for key in SAFE_KEYS {
228 if let Some(value) = std::env::var_os(key) {
229 environment.insert(OsString::from(key), value);
230 }
231 }
232 for (key, value) in std::env::vars_os() {
233 if key.to_string_lossy().starts_with("LC_") {
234 environment.insert(key, value);
235 }
236 }
237 if let Some(explicit) = explicit {
238 for (key, value) in explicit {
239 if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') {
240 bail!("invalid explicit command environment entry: {key:?}");
241 }
242 environment.insert(OsString::from(key), OsString::from(value));
243 }
244 }
245 remove_bootstrap_injection_variables(&mut environment);
246
247 let scratch = scratch.as_os_str().to_os_string();
248 for key in [
249 "HOME",
250 "USERPROFILE",
251 "APPDATA",
252 "LOCALAPPDATA",
253 "TMPDIR",
254 "TMP",
255 "TEMP",
256 "XDG_CACHE_HOME",
257 "XDG_CONFIG_HOME",
258 "XDG_DATA_HOME",
259 "XDG_STATE_HOME",
260 ] {
261 environment.insert(OsString::from(key), scratch.clone());
262 }
263 Ok(environment)
264}
265
266fn remove_bootstrap_injection_variables(environment: &mut BTreeMap<OsString, OsString>) {
267 const BLOCKED: &[&str] = &[
268 "BASH_ENV",
269 "ENV",
270 "NODE_OPTIONS",
271 "NODE_PATH",
272 "PYTHONHOME",
273 "PYTHONPATH",
274 "PYTHONSTARTUP",
275 "PYTHONINSPECT",
276 "RUBYOPT",
277 "RUBYLIB",
278 "PERL5OPT",
279 "PERL5LIB",
280 "LUA_INIT",
281 "JAVA_TOOL_OPTIONS",
282 "JDK_JAVA_OPTIONS",
283 "_JAVA_OPTIONS",
284 "LD_PRELOAD",
285 "LD_LIBRARY_PATH",
286 "DYLD_INSERT_LIBRARIES",
287 "DYLD_LIBRARY_PATH",
288 ];
289 environment.retain(|key, _| {
290 let key = key.to_string_lossy();
291 !BLOCKED
292 .iter()
293 .any(|blocked| key.eq_ignore_ascii_case(blocked))
294 && !key.to_ascii_uppercase().starts_with("LUA_INIT_")
295 });
296}
297
298pub(super) fn resolve_executable(
299 binary: impl Into<PathBuf>,
300 excluded_root: &Path,
301) -> Result<PathBuf> {
302 let excluded_root = excluded_root.canonicalize().with_context(|| {
308 format!(
309 "failed to resolve native sandbox workspace while validating executable: {}",
310 excluded_root.display()
311 )
312 })?;
313 let binary = binary.into();
314 let candidate = if binary.components().count() == 1 {
315 find_executable_on_path(&binary, &excluded_root).ok_or_else(|| {
316 anyhow::anyhow!(
317 "required native sandbox executable was not found on PATH: {}",
318 binary.display()
319 )
320 })?
321 } else {
322 binary
323 };
324 let candidate = candidate
325 .canonicalize()
326 .with_context(|| format!("failed to resolve executable {}", candidate.display()))?;
327 if !candidate.is_file() || !is_executable(&candidate) {
328 bail!(
329 "native sandbox executable is not executable: {}",
330 candidate.display()
331 );
332 }
333 if candidate.starts_with(&excluded_root) {
334 bail!(
335 "refusing native sandbox executable from inside the active workspace: {}",
336 candidate.display()
337 );
338 }
339 Ok(candidate)
340}
341
342fn find_executable_on_path(binary: &Path, excluded_root: &Path) -> Option<PathBuf> {
343 let path = std::env::var_os("PATH")?;
344 for directory in std::env::split_paths(&path) {
345 if !directory.is_absolute() {
346 continue;
347 }
348 let candidate = directory.join(binary);
349 if executable_is_trusted(&candidate, excluded_root) {
350 return candidate.canonicalize().ok();
351 }
352 #[cfg(windows)]
353 for extension in executable_extensions() {
354 let mut name = binary.as_os_str().to_os_string();
355 name.push(extension);
356 let candidate = directory.join(name);
357 if executable_is_trusted(&candidate, excluded_root) {
358 return candidate.canonicalize().ok();
359 }
360 }
361 }
362 None
363}
364
365fn executable_is_trusted(candidate: &Path, excluded_root: &Path) -> bool {
366 if !candidate.is_file() || !is_executable(candidate) {
367 return false;
368 }
369 candidate
370 .canonicalize()
371 .is_ok_and(|resolved| !resolved.starts_with(excluded_root))
372}
373
374#[cfg(windows)]
375fn executable_extensions() -> Vec<OsString> {
376 std::env::var_os("PATHEXT")
377 .map(|value| {
378 value
379 .to_string_lossy()
380 .split(';')
381 .filter(|value| !value.is_empty())
382 .map(OsString::from)
383 .collect()
384 })
385 .unwrap_or_else(|| {
386 [".COM", ".EXE", ".BAT", ".CMD"]
387 .into_iter()
388 .map(OsString::from)
389 .collect()
390 })
391}
392
393fn is_executable(path: &Path) -> bool {
394 #[cfg(unix)]
395 {
396 use std::os::unix::fs::PermissionsExt;
397 path.metadata()
398 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
399 .unwrap_or(false)
400 }
401 #[cfg(not(unix))]
402 {
403 path.is_file()
404 }
405}
406
407pub fn sensitive_paths() -> Vec<PathBuf> {
409 let mut paths = dirs::home_dir()
410 .map(|home| default_sensitive_paths(&home))
411 .unwrap_or_default();
412
413 extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
414 extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
415 extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
416 extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
417 for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
418 extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
419 }
420 for variable in [
421 "A3S_KIMI_DESKTOP_HOME",
422 "KIMI_DESKTOP_HOME",
423 "WORKBUDDY_CONFIG_DIR",
424 "CODEBUDDY_CONFIG_DIR",
425 ] {
426 extend_configured_secret(&mut paths, variable, None);
427 }
428 paths
429}
430
431fn read_denied_roots() -> Vec<PathBuf> {
432 let mut roots = Vec::new();
433 if let Some(home) = dirs::home_dir() {
434 roots.push(home.canonicalize().unwrap_or(home));
435 }
436 let temp = std::env::temp_dir();
437 roots.push(temp.canonicalize().unwrap_or(temp));
438 roots
439}
440
441fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
442 const TOOLCHAIN_ROOTS: &[&str] = &[
443 "CARGO_HOME",
444 "RUSTUP_HOME",
445 "GOPATH",
446 "GOROOT",
447 "GOMODCACHE",
448 "NVM_DIR",
449 "FNM_DIR",
450 "VOLTA_HOME",
451 "BUN_INSTALL",
452 "DENO_DIR",
453 "PNPM_HOME",
454 "JAVA_HOME",
455 "GRADLE_USER_HOME",
456 "MAVEN_HOME",
457 "SDKROOT",
458 "DEVELOPER_DIR",
459 ];
460
461 let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
462 for variable in TOOLCHAIN_ROOTS {
463 let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
464 continue;
465 };
466 let path = PathBuf::from(path);
467 if path.is_absolute() && path.exists() {
468 paths.push(path.canonicalize().unwrap_or(path));
469 }
470 }
471 if let Some(path) = std::env::var_os("PATH") {
472 paths.extend(std::env::split_paths(&path).filter_map(|path| {
473 if !path.is_absolute() || !path.exists() {
474 return None;
475 }
476 path.canonicalize().ok()
477 }));
478 }
479 paths
480}
481
482fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
483 [
484 ".ssh",
485 ".gnupg",
486 ".aws",
487 ".azure",
488 ".kube",
489 ".docker",
490 ".config/gcloud",
491 ".config/gh",
492 ".netrc",
493 ".npmrc",
494 ".pypirc",
495 ".cargo/credentials",
496 ".cargo/credentials.toml",
497 ".codex/auth.json",
498 ".claude/.credentials.json",
499 ".claude.json",
500 ".git-credentials",
501 ".config/git/credentials",
502 ".workbuddy",
503 "credentials/kimi-code.json",
504 ".kimi-code/credentials/kimi-code.json",
505 ".kimi/credentials/kimi-code.json",
506 ".config/kimi-desktop/daimon-share",
507 "Library/Application Support/kimi-desktop/daimon-share",
508 ".config/opencode/auth.json",
509 ".local/share/opencode/auth.json",
510 ".gemini/oauth_creds.json",
511 ".terraform.d/credentials.tfrc.json",
512 ".local/share/keyrings",
513 ".password-store",
514 ".a3s/os-auth.json",
515 "Library/Keychains",
516 ]
517 .into_iter()
518 .map(|path| home.join(path))
519 .collect()
520}
521
522const FIXED_WORKSPACE_SECRET_FILES: &[&str] = &[
523 ".env",
524 ".env.local",
525 ".env.development",
526 ".env.production",
527 ".env.test",
528 ".netrc",
529 ".npmrc",
530 ".pypirc",
531 ".git-credentials",
532 ".a3s/os-auth.json",
533 ".codex/auth.json",
534 ".claude/.credentials.json",
535 ".claude.json",
536];
537
538fn fixed_workspace_secret_paths(workspace: &Path) -> Vec<PathBuf> {
539 FIXED_WORKSPACE_SECRET_FILES
540 .iter()
541 .map(|path| workspace.join(path))
542 .collect()
543}
544
545pub fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
547 let mut paths = fixed_workspace_secret_paths(workspace);
548 paths.extend(scan_workspace_security(workspace)?.nested_env);
549 Ok(paths)
550}
551
552pub fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
559 let mut hardlinks = scan_workspace_security(workspace)?.source_hardlinks;
560 deduplicate_paths(&mut hardlinks);
561 Ok(hardlinks)
562}
563
564#[derive(Debug, Default)]
565struct WorkspaceSecurityScan {
566 nested_env: Vec<PathBuf>,
567 source_hardlinks: Vec<PathBuf>,
568}
569
570fn scan_workspace_security(workspace: &Path) -> Result<WorkspaceSecurityScan> {
572 let mut pending = vec![(workspace.to_path_buf(), 0usize, true)];
573 let mut scanned = 0usize;
574 let mut scan = WorkspaceSecurityScan::default();
575
576 while let Some((directory, depth, collect_hardlinks)) = pending.pop() {
577 let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
578 format!(
579 "failed to scan native sandbox workspace {}",
580 directory.display()
581 )
582 })?
583 else {
584 continue;
585 };
586 for entry in entries {
587 let Some(entry) = workspace_scan_result(entry, || {
588 format!(
589 "failed to enumerate native sandbox workspace {}",
590 directory.display()
591 )
592 })?
593 else {
594 continue;
595 };
596 scanned = next_workspace_scan_entry(scanned, MAX_WORKSPACE_SCAN_ENTRIES)?;
597 let path = entry.path();
598 let file_name = entry.file_name();
599 let Some(file_type) = workspace_scan_result(entry.file_type(), || {
600 format!(
601 "failed to inspect native sandbox workspace path {}",
602 path.display()
603 )
604 })?
605 else {
606 continue;
607 };
608
609 if file_name.to_str().is_some_and(|name| {
610 name.get(..4)
611 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(".env"))
612 }) {
613 scan.nested_env.push(path.clone());
614 }
615
616 if file_type.is_symlink() {
617 continue;
618 }
619 if file_type.is_dir() {
620 if should_skip_workspace_scan_directory(&file_name) {
621 continue;
622 }
623 ensure_workspace_scan_depth(depth, &path)?;
624 let child_collect_hardlinks =
625 collect_hardlinks && !is_protected_workspace_directory(&file_name);
626 pending.push((path, depth + 1, child_collect_hardlinks));
627 continue;
628 }
629 if !collect_hardlinks || !file_type.is_file() {
630 continue;
631 }
632 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
633 format!(
634 "failed to inspect native sandbox workspace path {}",
635 path.display()
636 )
637 })?
638 else {
639 continue;
640 };
641 if metadata.file_type().is_symlink() || !metadata.is_file() {
642 continue;
643 }
644 if hard_link_count(&path, &metadata) > 1 {
645 scan.source_hardlinks.push(path);
646 }
647 }
648 }
649 Ok(scan)
650}
651
652pub fn workspace_credential_hardlink_aliases(
659 workspace: &Path,
660 sensitive: &[PathBuf],
661) -> Result<Vec<PathBuf>> {
662 let mut wanted = HashSet::new();
663 for path in sensitive {
664 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(path), || {
665 format!(
666 "failed to inspect native sandbox credential path {}",
667 path.display()
668 )
669 })?
670 else {
671 continue;
672 };
673 if metadata.file_type().is_symlink() || !metadata.is_file() {
674 continue;
675 }
676 if hard_link_count(path, &metadata) <= 1 {
677 continue;
678 }
679 let Some(identity) = FileIdentity::from_path(path, &metadata) else {
680 continue;
681 };
682 wanted.insert(identity);
683 }
684 if wanted.is_empty() {
685 return Ok(Vec::new());
686 }
687
688 let mut pending = vec![(workspace.to_path_buf(), 0usize, false)];
689 let mut scanned = 0usize;
690 let mut aliases = Vec::new();
691 let sensitive_set: HashSet<&Path> = sensitive.iter().map(PathBuf::as_path).collect();
692
693 while let Some((directory, depth, in_package_store)) = pending.pop() {
694 let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
695 format!(
696 "failed to scan native sandbox package stores under {}",
697 directory.display()
698 )
699 })?
700 else {
701 continue;
702 };
703 for entry in entries {
704 let Some(entry) = workspace_scan_result(entry, || {
705 format!(
706 "failed to enumerate native sandbox package stores under {}",
707 directory.display()
708 )
709 })?
710 else {
711 continue;
712 };
713 scanned = next_workspace_scan_entry(scanned, MAX_CREDENTIAL_ALIAS_SCAN_ENTRIES)?;
714 let path = entry.path();
715 let file_name = entry.file_name();
716 let Some(file_type) = workspace_scan_result(entry.file_type(), || {
717 format!(
718 "failed to inspect native sandbox package-store path {}",
719 path.display()
720 )
721 })?
722 else {
723 continue;
724 };
725 if file_type.is_symlink() {
726 continue;
727 }
728 if file_type.is_dir() {
729 if !in_package_store && is_git_directory(&file_name) {
730 continue;
731 }
732 let child_in_store =
733 in_package_store || is_package_or_build_store_directory(&file_name);
734 if !child_in_store && is_protected_workspace_directory(&file_name) {
735 continue;
736 }
737 ensure_workspace_scan_depth(depth, &path)?;
738 pending.push((path, depth + 1, child_in_store));
739 continue;
740 }
741 if !in_package_store || !file_type.is_file() {
742 continue;
743 }
744 if sensitive_set.contains(path.as_path()) {
745 continue;
746 }
747 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
748 format!(
749 "failed to inspect native sandbox package-store path {}",
750 path.display()
751 )
752 })?
753 else {
754 continue;
755 };
756 if metadata.file_type().is_symlink() || !metadata.is_file() {
757 continue;
758 }
759 if hard_link_count(&path, &metadata) <= 1 {
760 continue;
761 }
762 let Some(identity) = FileIdentity::from_path(&path, &metadata) else {
763 continue;
764 };
765 if wanted.contains(&identity) {
766 aliases.push(path);
767 }
768 }
769 }
770 deduplicate_paths(&mut aliases);
771 Ok(aliases)
772}
773
774fn workspace_scan_result<T>(
775 result: std::io::Result<T>,
776 context: impl FnOnce() -> String,
777) -> Result<Option<T>> {
778 match result {
779 Ok(value) => Ok(Some(value)),
780 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
781 Err(error) => Err(error).with_context(context),
782 }
783}
784
785fn next_workspace_scan_entry(scanned: usize, limit: usize) -> Result<usize> {
786 let scanned = scanned
787 .checked_add(1)
788 .context("native sandbox workspace scan entry count overflowed")?;
789 if scanned > limit {
790 bail!("native sandbox workspace exceeds the {limit} entry scan limit");
791 }
792 Ok(scanned)
793}
794
795fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
796 if depth >= MAX_WORKSPACE_SCAN_DEPTH {
797 bail!(
798 "native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
799 path.display()
800 );
801 }
802 Ok(())
803}
804
805pub fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
808 is_git_directory(name) || is_package_or_build_store_directory(name)
809}
810
811fn is_package_or_build_store_directory(name: &OsStr) -> bool {
812 name.to_str().is_some_and(|name| {
813 ["node_modules", "target"]
814 .iter()
815 .any(|skipped| name.eq_ignore_ascii_case(skipped))
816 })
817}
818
819fn is_git_directory(name: &OsStr) -> bool {
820 name.to_str()
821 .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
822}
823
824fn is_protected_workspace_directory(name: &OsStr) -> bool {
825 name.to_str().is_some_and(|name| {
826 PROTECTED_WORKSPACE_DIRECTORIES
827 .iter()
828 .any(|protected| name.eq_ignore_ascii_case(protected))
829 })
830}
831
832#[cfg(unix)]
833#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
834struct FileIdentity {
835 device: u64,
836 inode: u64,
837}
838
839#[cfg(unix)]
840impl FileIdentity {
841 fn from_path(_path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
842 use std::os::unix::fs::MetadataExt;
843 Some(Self {
844 device: metadata.dev(),
845 inode: metadata.ino(),
846 })
847 }
848}
849
850#[cfg(windows)]
851#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
852struct FileIdentity {
853 volume: u32,
854 index: u64,
855}
856
857#[cfg(windows)]
858impl FileIdentity {
859 fn from_path(path: &Path, _metadata: &std::fs::Metadata) -> Option<Self> {
860 use std::os::windows::io::AsRawHandle;
861 use windows_sys::Win32::Storage::FileSystem::{
862 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
863 };
864
865 let file = std::fs::File::open(path).ok()?;
866 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
867 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
869 return None;
870 }
871 Some(Self {
872 volume: information.dwVolumeSerialNumber,
873 index: (u64::from(information.nFileIndexHigh) << 32)
874 | u64::from(information.nFileIndexLow),
875 })
876 }
877}
878
879#[cfg(not(any(unix, windows)))]
880#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
881struct FileIdentity;
882
883#[cfg(not(any(unix, windows)))]
884impl FileIdentity {
885 fn from_path(_path: &Path, _metadata: &std::fs::Metadata) -> Option<Self> {
886 None
887 }
888}
889
890#[cfg(unix)]
891pub fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
894 use std::os::unix::fs::MetadataExt;
895 metadata.nlink()
896}
897
898#[cfg(windows)]
899pub fn hard_link_count(path: &Path, metadata: &std::fs::Metadata) -> u64 {
902 let Ok(file) = std::fs::File::open(path) else {
903 return u64::MAX;
904 };
905 hard_link_count_for_open_file(&file, metadata)
906}
907
908#[cfg(windows)]
909pub fn hard_link_count_for_open_file<T>(file: &T, _metadata: &std::fs::Metadata) -> u64
911where
912 T: std::os::windows::io::AsRawHandle,
913{
914 use windows_sys::Win32::Storage::FileSystem::{
915 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
916 };
917
918 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
919 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
921 return u64::MAX;
922 }
923 u64::from(information.nNumberOfLinks.max(1))
924}
925
926#[cfg(unix)]
928pub fn hard_link_count_for_open_file<T>(_file: &T, metadata: &std::fs::Metadata) -> u64 {
929 use std::os::unix::fs::MetadataExt;
930 metadata.nlink()
931}
932
933#[cfg(not(any(unix, windows)))]
934pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
936 1
937}
938
939#[cfg(not(any(unix, windows)))]
940pub fn hard_link_count_for_open_file<T>(_file: &T, _metadata: &std::fs::Metadata) -> u64 {
942 1
943}
944
945fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
946 let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
947 return;
948 };
949 let root = PathBuf::from(root);
950 if !root.is_absolute() {
951 return;
952 }
953 paths.push(match suffix {
954 Some(suffix) => root.join(suffix),
955 None => root,
956 });
957}
958
959fn protected_workspace_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
960 let mut paths = PROTECTED_WORKSPACE_DIRECTORIES
961 .iter()
962 .chain(PROTECTED_WORKSPACE_FILES)
963 .copied()
964 .map(|path| workspace.join(path))
965 .collect::<Vec<_>>();
966
967 let entries = std::fs::read_dir(workspace).with_context(|| {
972 format!(
973 "failed to scan protected workspace roots {}",
974 workspace.display()
975 )
976 })?;
977 for entry in entries {
978 let entry = entry.with_context(|| {
979 format!(
980 "failed to enumerate protected workspace roots {}",
981 workspace.display()
982 )
983 })?;
984 let name = entry.file_name();
985 if PROTECTED_WORKSPACE_DIRECTORIES
986 .iter()
987 .chain(PROTECTED_WORKSPACE_FILES)
988 .any(|protected| {
989 name.to_str()
990 .is_some_and(|name| name.eq_ignore_ascii_case(protected))
991 })
992 {
993 paths.push(entry.path());
994 }
995 }
996 Ok(paths)
997}
998
999fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
1000 let dot_git = workspace.join(".git");
1001 let dot_git = if dot_git.exists() {
1002 dot_git
1003 } else {
1004 std::fs::read_dir(workspace)
1005 .ok()?
1006 .filter_map(Result::ok)
1007 .find(|entry| {
1008 entry
1009 .file_name()
1010 .to_str()
1011 .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
1012 })
1013 .map(|entry| entry.path())?
1014 };
1015 if dot_git.is_dir() {
1016 return dot_git.canonicalize().ok();
1017 }
1018 let source = std::fs::read_to_string(dot_git).ok()?;
1019 let relative = source.trim().strip_prefix("gitdir:")?.trim();
1020 let path = Path::new(relative);
1021 let path = if path.is_absolute() {
1022 path.to_path_buf()
1023 } else {
1024 workspace.join(path)
1025 };
1026 path.canonicalize().ok()
1027}
1028
1029fn expand_existing_canonical_paths(paths: &mut Vec<PathBuf>) {
1030 let resolved = paths
1031 .iter()
1032 .filter_map(|path| path.canonicalize().ok())
1033 .collect::<Vec<_>>();
1034 paths.extend(resolved);
1035 deduplicate_paths(paths);
1036}
1037
1038pub(super) fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
1039 paths.sort();
1040 paths.dedup();
1041}
1042
1043fn remove_redundant_descendants(paths: &mut Vec<PathBuf>) {
1044 deduplicate_paths(paths);
1045 let candidates = paths.clone();
1046 paths.retain(|path| {
1047 !candidates
1048 .iter()
1049 .any(|ancestor| ancestor != path && path.starts_with(ancestor))
1050 });
1051}
1052
1053#[cfg(any(target_os = "linux", target_os = "macos"))]
1054pub(super) fn path_ancestors(path: &Path) -> Vec<PathBuf> {
1055 let mut ancestors = path
1056 .parent()
1057 .into_iter()
1058 .flat_map(Path::ancestors)
1059 .take_while(|ancestor| ancestor.parent().is_some())
1060 .map(Path::to_path_buf)
1061 .collect::<Vec<_>>();
1062 ancestors.reverse();
1063 ancestors
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069
1070 #[test]
1071 fn child_environment_removes_runtime_injection_and_rehomes_state() {
1072 let scratch = tempfile::tempdir().unwrap();
1073 let explicit = HashMap::from([
1074 ("SAFE_VALUE".to_string(), "visible".to_string()),
1075 ("BASH_ENV".to_string(), "/tmp/attack".to_string()),
1076 ("LD_PRELOAD".to_string(), "/tmp/attack.so".to_string()),
1077 ]);
1078 let environment = compose_child_env(Some(&explicit), scratch.path()).unwrap();
1079
1080 assert_eq!(
1081 environment.get(OsStr::new("SAFE_VALUE")),
1082 Some(&OsString::from("visible"))
1083 );
1084 assert!(!environment.contains_key(OsStr::new("BASH_ENV")));
1085 assert!(!environment.contains_key(OsStr::new("LD_PRELOAD")));
1086 assert_eq!(
1087 environment.get(OsStr::new("HOME")),
1088 Some(&scratch.path().as_os_str().to_os_string())
1089 );
1090 }
1091
1092 #[test]
1093 fn child_environment_removes_case_insensitive_bootstrap_variables() {
1094 let scratch = tempfile::tempdir().unwrap();
1095 let explicit = HashMap::from([
1096 ("bash_env".to_string(), "attack".to_string()),
1097 ("Ld_PreLoad".to_string(), "attack.so".to_string()),
1098 ("LUA_INIT_script".to_string(), "attack.lua".to_string()),
1099 ("SAFE_VALUE".to_string(), "visible".to_string()),
1100 ]);
1101 let environment = compose_child_env(Some(&explicit), scratch.path()).unwrap();
1102
1103 assert!(!environment.keys().any(|key| {
1104 matches!(
1105 key.to_string_lossy().to_ascii_uppercase().as_str(),
1106 "BASH_ENV" | "LD_PRELOAD"
1107 ) || key
1108 .to_string_lossy()
1109 .to_ascii_uppercase()
1110 .starts_with("LUA_INIT_")
1111 }));
1112 assert_eq!(
1113 environment.get(OsStr::new("SAFE_VALUE")),
1114 Some(&OsString::from("visible"))
1115 );
1116 }
1117
1118 #[test]
1119 fn protected_path_matching_is_case_insensitive_and_traversal_safe() {
1120 for path in [
1121 ".git/config",
1122 ".GIT/HEAD",
1123 r".a3s\policy.acl",
1124 ".mcp.json",
1125 ".zshrc",
1126 ] {
1127 assert!(is_protected_workspace_path(path), "{path}");
1128 }
1129 for path in [
1130 "src/.git/config",
1131 "../.git/config",
1132 ".gitignore",
1133 "src/main.rs",
1134 ] {
1135 assert!(!is_protected_workspace_path(path), "{path}");
1136 }
1137 }
1138
1139 #[test]
1140 fn policy_discovers_case_variant_control_metadata() {
1141 let workspace = tempfile::tempdir().unwrap();
1142 let scratch = tempfile::tempdir().unwrap();
1143 std::fs::create_dir(workspace.path().join(".GIT")).unwrap();
1144 std::fs::write(workspace.path().join(".MCP.JSON"), "control").unwrap();
1145
1146 let policy = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1147 let workspace = workspace.path().canonicalize().unwrap();
1148 assert!(policy.deny_write.contains(&workspace.join(".GIT")));
1149 assert!(policy.deny_write.contains(&workspace.join(".MCP.JSON")));
1150 }
1151
1152 #[test]
1153 fn git_worktree_pointer_is_resolved_for_case_variant_gitfiles() {
1154 let parent = tempfile::tempdir().unwrap();
1155 let workspace = parent.path().join("workspace");
1156 let git_dir = parent.path().join("git-dir");
1157 std::fs::create_dir(&workspace).unwrap();
1158 std::fs::create_dir(&git_dir).unwrap();
1159 std::fs::write(workspace.join(".GIT"), "gitdir: ../git-dir\n").unwrap();
1160 let scratch = tempfile::tempdir().unwrap();
1161
1162 let policy = SandboxPolicy::for_execution(&workspace, scratch.path()).unwrap();
1163 assert!(policy.deny_write.contains(&git_dir.canonicalize().unwrap()));
1164 }
1165
1166 #[test]
1167 fn nested_secret_scan_matches_case_variant_environment_files() {
1168 let workspace = tempfile::tempdir().unwrap();
1169 std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1170 std::fs::write(workspace.path().join("src/config/.ENV.local"), "secret").unwrap();
1171
1172 let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1173 assert!(paths.contains(&workspace.path().join("src/config/.ENV.local")));
1174 }
1175
1176 #[test]
1177 fn monorepo_workspace_scan_stays_under_entry_limit() {
1178 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
1179 let workspace = workspace.canonicalize().unwrap();
1180 let scratch = tempfile::tempdir().unwrap();
1181 let start = std::time::Instant::now();
1182 let result = SandboxPolicy::for_execution(&workspace, scratch.path());
1183 let elapsed = start.elapsed();
1184 match result {
1185 Ok(_) => eprintln!("monorepo_scan_ok elapsed_ms={}", elapsed.as_millis()),
1186 Err(error) => panic!(
1187 "monorepo_scan_failed elapsed_ms={}: {error:#}",
1188 elapsed.as_millis()
1189 ),
1190 }
1191 }
1192
1193 #[test]
1194 fn scan_directory_filter_handles_case_variants() {
1195 for name in [".git", ".GIT", "Node_Modules", "TARGET"] {
1196 assert!(should_skip_workspace_scan_directory(OsStr::new(name)));
1197 }
1198 assert!(!should_skip_workspace_scan_directory(OsStr::new("src")));
1199 }
1200
1201 #[test]
1202 fn nested_environment_files_and_hardlinks_enter_the_deny_set() {
1203 let workspace = tempfile::tempdir().unwrap();
1204 let scratch = tempfile::tempdir().unwrap();
1205 std::fs::create_dir_all(workspace.path().join("nested")).unwrap();
1206 std::fs::write(workspace.path().join("nested/.env.secret"), "secret").unwrap();
1207 let outside = scratch.path().join("outside-secret");
1208 std::fs::write(&outside, "outside").unwrap();
1209 std::fs::hard_link(&outside, workspace.path().join("hardlink-secret")).unwrap();
1210
1211 let policy = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1212 let workspace = workspace.path().canonicalize().unwrap();
1213
1214 assert!(policy
1215 .deny_read
1216 .contains(&workspace.join("nested/.env.secret")));
1217 assert!(policy
1218 .deny_read
1219 .contains(&workspace.join("hardlink-secret")));
1220 assert!(policy
1221 .deny_write
1222 .contains(&workspace.join("hardlink-secret")));
1223 }
1224
1225 #[cfg(any(unix, windows))]
1226 #[test]
1227 fn hardlink_scan_skips_build_and_package_stores() {
1228 let workspace = tempfile::tempdir().unwrap();
1229 let outside = tempfile::tempdir().unwrap();
1230 let source = outside.path().join("source");
1231 std::fs::write(&source, "outside").unwrap();
1232 for directory in ["node_modules", "target", ".a3s"] {
1233 let directory = workspace.path().join(directory);
1234 std::fs::create_dir_all(&directory).unwrap();
1235 std::fs::hard_link(&source, directory.join("linked")).unwrap();
1236 }
1237 std::fs::create_dir_all(workspace.path().join("src")).unwrap();
1238 std::fs::hard_link(&source, workspace.path().join("src/linked")).unwrap();
1239
1240 let hardlinks = workspace_hardlink_paths(workspace.path()).unwrap();
1241 assert_eq!(hardlinks.len(), 1);
1242 assert!(hardlinks[0].ends_with("src/linked"));
1243 assert!(!hardlinks
1244 .iter()
1245 .any(|path| path.ends_with("node_modules/linked")));
1246 assert!(!hardlinks.iter().any(|path| path.ends_with("target/linked")));
1247 assert!(!hardlinks.iter().any(|path| path.ends_with(".a3s/linked")));
1248 }
1249
1250 #[cfg(any(unix, windows))]
1251 #[test]
1252 fn credential_hardlink_aliases_inside_package_stores_enter_the_deny_set() {
1253 let workspace = tempfile::tempdir().unwrap();
1254 let scratch = tempfile::tempdir().unwrap();
1255 let env_path = workspace.path().join(".env");
1256 std::fs::write(&env_path, "SECRET=1").unwrap();
1257 for directory in ["node_modules", "target"] {
1258 let directory = workspace.path().join(directory);
1259 std::fs::create_dir_all(&directory).unwrap();
1260 std::fs::hard_link(&env_path, directory.join("linked-secret")).unwrap();
1261 }
1262 let outside = scratch.path().join("ordinary");
1265 std::fs::write(&outside, "ordinary").unwrap();
1266 std::fs::hard_link(&outside, workspace.path().join("node_modules/ordinary")).unwrap();
1267
1268 let policy = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1269 let workspace = workspace.path().canonicalize().unwrap();
1270
1271 assert!(policy
1272 .deny_write
1273 .contains(&workspace.join("node_modules/linked-secret")));
1274 assert!(policy
1275 .deny_write
1276 .contains(&workspace.join("target/linked-secret")));
1277 assert!(!policy
1278 .deny_write
1279 .contains(&workspace.join("node_modules/ordinary")));
1280 }
1281
1282 #[test]
1283 fn nested_secret_scan_skips_control_and_build_stores() {
1284 let workspace = tempfile::tempdir().unwrap();
1285 std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1286 std::fs::create_dir_all(workspace.path().join("node_modules/package")).unwrap();
1287 std::fs::create_dir_all(workspace.path().join("target/debug")).unwrap();
1288 std::fs::create_dir_all(workspace.path().join(".git")).unwrap();
1289 for path in [
1290 "src/config/.env.secret",
1291 "node_modules/package/.env.secret",
1292 "target/debug/.env.secret",
1293 ".git/.env.secret",
1294 ] {
1295 std::fs::write(workspace.path().join(path), "secret").unwrap();
1296 }
1297
1298 let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1299 assert!(paths.contains(&workspace.path().join("src/config/.env.secret")));
1300 assert!(!paths.contains(&workspace.path().join("node_modules/package/.env.secret")));
1301 assert!(!paths.contains(&workspace.path().join("target/debug/.env.secret")));
1302 assert!(!paths.contains(&workspace.path().join(".git/.env.secret")));
1303 }
1304
1305 #[cfg(unix)]
1306 #[test]
1307 fn nested_secret_scan_fails_closed_at_depth_limit() {
1308 let workspace = tempfile::tempdir().unwrap();
1309 let mut current = workspace.path().to_path_buf();
1310 for index in 0..=MAX_WORKSPACE_SCAN_DEPTH {
1311 current.push(format!("level-{index}"));
1312 std::fs::create_dir(¤t).unwrap();
1313 }
1314
1315 let error = workspace_sensitive_paths(workspace.path()).unwrap_err();
1316 assert!(error.to_string().contains("depth"), "{error:#}");
1317 }
1318
1319 #[test]
1320 fn executable_resolution_rejects_workspace_tools() {
1321 let workspace = tempfile::tempdir().unwrap();
1322 let candidate = workspace.path().join("untrusted-tool");
1323 std::fs::write(&candidate, "#!/bin/sh\nexit 0\n").unwrap();
1324 #[cfg(unix)]
1325 {
1326 use std::os::unix::fs::PermissionsExt;
1327 std::fs::set_permissions(&candidate, std::fs::Permissions::from_mode(0o755)).unwrap();
1328 }
1329
1330 let error = resolve_executable(&candidate, workspace.path()).unwrap_err();
1331 assert!(error.to_string().contains("inside the active workspace"));
1332 }
1333
1334 #[cfg(any(target_os = "linux", target_os = "macos"))]
1335 #[test]
1336 fn path_ancestors_exclude_the_filesystem_root() {
1337 let ancestors = path_ancestors(Path::new("/a/b/c"));
1338 assert_eq!(ancestors, vec![PathBuf::from("/a"), PathBuf::from("/a/b")]);
1339 }
1340
1341 #[cfg(any(target_os = "linux", windows))]
1342 #[test]
1343 fn only_protected_workspace_roots_require_directory_placeholders() {
1344 let workspace = Path::new("/workspace");
1345 assert!(requires_directory_placeholder(
1346 workspace,
1347 &workspace.join(".a3s")
1348 ));
1349 assert!(requires_directory_placeholder(
1350 workspace,
1351 &workspace.join(".GIT")
1352 ));
1353 assert!(!requires_directory_placeholder(
1354 workspace,
1355 &workspace.join(".gitmodules")
1356 ));
1357 assert!(!requires_directory_placeholder(
1358 workspace,
1359 &workspace.join(".a3s/os-auth.json")
1360 ));
1361 assert!(!requires_directory_placeholder(
1362 workspace,
1363 Path::new("/outside/.a3s")
1364 ));
1365 }
1366
1367 #[cfg(unix)]
1368 #[test]
1369 fn protected_workspace_symlinks_fail_closed() {
1370 use std::os::unix::fs::symlink;
1371
1372 let workspace = tempfile::tempdir().unwrap();
1373 let scratch = tempfile::tempdir().unwrap();
1374 let outside = tempfile::tempdir().unwrap();
1375 symlink(outside.path(), workspace.path().join(".git")).unwrap();
1376
1377 let error = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap_err();
1378 assert!(error.to_string().contains("symbolic link"), "{error:#}");
1379 }
1380}