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