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 binary = binary.into();
294 let candidate = if binary.components().count() == 1 {
295 find_executable_on_path(&binary, excluded_root).ok_or_else(|| {
296 anyhow::anyhow!(
297 "required native sandbox executable was not found on PATH: {}",
298 binary.display()
299 )
300 })?
301 } else {
302 binary
303 };
304 let candidate = candidate
305 .canonicalize()
306 .with_context(|| format!("failed to resolve executable {}", candidate.display()))?;
307 if !candidate.is_file() || !is_executable(&candidate) {
308 bail!(
309 "native sandbox executable is not executable: {}",
310 candidate.display()
311 );
312 }
313 if candidate.starts_with(excluded_root) {
314 bail!(
315 "refusing native sandbox executable from inside the active workspace: {}",
316 candidate.display()
317 );
318 }
319 Ok(candidate)
320}
321
322fn find_executable_on_path(binary: &Path, excluded_root: &Path) -> Option<PathBuf> {
323 let path = std::env::var_os("PATH")?;
324 for directory in std::env::split_paths(&path) {
325 if !directory.is_absolute() {
326 continue;
327 }
328 let candidate = directory.join(binary);
329 if executable_is_trusted(&candidate, excluded_root) {
330 return candidate.canonicalize().ok();
331 }
332 #[cfg(windows)]
333 for extension in executable_extensions() {
334 let mut name = binary.as_os_str().to_os_string();
335 name.push(extension);
336 let candidate = directory.join(name);
337 if executable_is_trusted(&candidate, excluded_root) {
338 return candidate.canonicalize().ok();
339 }
340 }
341 }
342 None
343}
344
345fn executable_is_trusted(candidate: &Path, excluded_root: &Path) -> bool {
346 if !candidate.is_file() || !is_executable(candidate) {
347 return false;
348 }
349 candidate
350 .canonicalize()
351 .is_ok_and(|resolved| !resolved.starts_with(excluded_root))
352}
353
354#[cfg(windows)]
355fn executable_extensions() -> Vec<OsString> {
356 std::env::var_os("PATHEXT")
357 .map(|value| {
358 value
359 .to_string_lossy()
360 .split(';')
361 .filter(|value| !value.is_empty())
362 .map(OsString::from)
363 .collect()
364 })
365 .unwrap_or_else(|| {
366 [".COM", ".EXE", ".BAT", ".CMD"]
367 .into_iter()
368 .map(OsString::from)
369 .collect()
370 })
371}
372
373fn is_executable(path: &Path) -> bool {
374 #[cfg(unix)]
375 {
376 use std::os::unix::fs::PermissionsExt;
377 path.metadata()
378 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
379 .unwrap_or(false)
380 }
381 #[cfg(not(unix))]
382 {
383 path.is_file()
384 }
385}
386
387pub fn sensitive_paths() -> Vec<PathBuf> {
389 let mut paths = dirs::home_dir()
390 .map(|home| default_sensitive_paths(&home))
391 .unwrap_or_default();
392
393 extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
394 extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
395 extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
396 extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
397 for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
398 extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
399 }
400 for variable in [
401 "A3S_KIMI_DESKTOP_HOME",
402 "KIMI_DESKTOP_HOME",
403 "WORKBUDDY_CONFIG_DIR",
404 "CODEBUDDY_CONFIG_DIR",
405 ] {
406 extend_configured_secret(&mut paths, variable, None);
407 }
408 paths
409}
410
411fn read_denied_roots() -> Vec<PathBuf> {
412 let mut roots = Vec::new();
413 if let Some(home) = dirs::home_dir() {
414 roots.push(home.canonicalize().unwrap_or(home));
415 }
416 let temp = std::env::temp_dir();
417 roots.push(temp.canonicalize().unwrap_or(temp));
418 roots
419}
420
421fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
422 const TOOLCHAIN_ROOTS: &[&str] = &[
423 "CARGO_HOME",
424 "RUSTUP_HOME",
425 "GOPATH",
426 "GOROOT",
427 "GOMODCACHE",
428 "NVM_DIR",
429 "FNM_DIR",
430 "VOLTA_HOME",
431 "BUN_INSTALL",
432 "DENO_DIR",
433 "PNPM_HOME",
434 "JAVA_HOME",
435 "GRADLE_USER_HOME",
436 "MAVEN_HOME",
437 "SDKROOT",
438 "DEVELOPER_DIR",
439 ];
440
441 let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
442 for variable in TOOLCHAIN_ROOTS {
443 let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
444 continue;
445 };
446 let path = PathBuf::from(path);
447 if path.is_absolute() && path.exists() {
448 paths.push(path.canonicalize().unwrap_or(path));
449 }
450 }
451 if let Some(path) = std::env::var_os("PATH") {
452 paths.extend(std::env::split_paths(&path).filter_map(|path| {
453 if !path.is_absolute() || !path.exists() {
454 return None;
455 }
456 path.canonicalize().ok()
457 }));
458 }
459 paths
460}
461
462fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
463 [
464 ".ssh",
465 ".gnupg",
466 ".aws",
467 ".azure",
468 ".kube",
469 ".docker",
470 ".config/gcloud",
471 ".config/gh",
472 ".netrc",
473 ".npmrc",
474 ".pypirc",
475 ".cargo/credentials",
476 ".cargo/credentials.toml",
477 ".codex/auth.json",
478 ".claude/.credentials.json",
479 ".claude.json",
480 ".git-credentials",
481 ".config/git/credentials",
482 ".workbuddy",
483 "credentials/kimi-code.json",
484 ".kimi-code/credentials/kimi-code.json",
485 ".kimi/credentials/kimi-code.json",
486 ".config/kimi-desktop/daimon-share",
487 "Library/Application Support/kimi-desktop/daimon-share",
488 ".config/opencode/auth.json",
489 ".local/share/opencode/auth.json",
490 ".gemini/oauth_creds.json",
491 ".terraform.d/credentials.tfrc.json",
492 ".local/share/keyrings",
493 ".password-store",
494 ".a3s/os-auth.json",
495 "Library/Keychains",
496 ]
497 .into_iter()
498 .map(|path| home.join(path))
499 .collect()
500}
501
502pub fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
504 let mut paths = [
505 ".env",
506 ".env.local",
507 ".env.development",
508 ".env.production",
509 ".env.test",
510 ".netrc",
511 ".npmrc",
512 ".pypirc",
513 ".git-credentials",
514 ".a3s/os-auth.json",
515 ".codex/auth.json",
516 ".claude/.credentials.json",
517 ".claude.json",
518 ]
519 .into_iter()
520 .map(|path| workspace.join(path))
521 .collect::<Vec<_>>();
522 paths.extend(workspace_nested_env_paths(workspace)?);
523 Ok(paths)
524}
525
526fn workspace_nested_env_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
527 let mut pending = vec![(workspace.to_path_buf(), 0usize)];
528 let mut scanned = 0usize;
529 let mut paths = Vec::new();
530
531 while let Some((directory, depth)) = pending.pop() {
532 let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
533 format!(
534 "failed to scan native sandbox workspace {}",
535 directory.display()
536 )
537 })?
538 else {
539 continue;
540 };
541 for entry in entries {
542 let Some(entry) = workspace_scan_result(entry, || {
543 format!(
544 "failed to enumerate native sandbox workspace {}",
545 directory.display()
546 )
547 })?
548 else {
549 continue;
550 };
551 scanned = next_workspace_scan_entry(scanned)?;
552 let path = entry.path();
553 let Some(file_type) = workspace_scan_result(entry.file_type(), || {
554 format!(
555 "failed to inspect native sandbox workspace path {}",
556 path.display()
557 )
558 })?
559 else {
560 continue;
561 };
562 if entry
563 .file_name()
564 .to_str()
565 .is_some_and(|name| name.starts_with(".env"))
566 {
567 paths.push(path);
568 } else if file_type.is_dir() {
569 if should_skip_workspace_scan_directory(&entry.file_name()) {
570 continue;
571 }
572 ensure_workspace_scan_depth(depth, &path)?;
573 pending.push((path, depth + 1));
574 }
575 }
576 }
577 Ok(paths)
578}
579
580pub fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
582 let mut pending = vec![(workspace.to_path_buf(), 0usize)];
583 let mut scanned = 0usize;
584 let mut hardlinks = Vec::new();
585
586 while let Some((directory, depth)) = pending.pop() {
587 let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
588 format!(
589 "failed to scan native sandbox workspace {}",
590 directory.display()
591 )
592 })?
593 else {
594 continue;
595 };
596 for entry in entries {
597 let Some(entry) = workspace_scan_result(entry, || {
598 format!(
599 "failed to enumerate native sandbox workspace {}",
600 directory.display()
601 )
602 })?
603 else {
604 continue;
605 };
606 scanned = next_workspace_scan_entry(scanned)?;
607 let path = entry.path();
608 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
609 format!(
610 "failed to inspect native sandbox workspace path {}",
611 path.display()
612 )
613 })?
614 else {
615 continue;
616 };
617 if metadata.file_type().is_symlink() {
618 continue;
619 }
620 if metadata.is_dir() {
621 if should_skip_workspace_scan_directory(&entry.file_name()) {
622 continue;
623 }
624 ensure_workspace_scan_depth(depth, &path)?;
625 pending.push((path, depth + 1));
626 } else if metadata.is_file() && hard_link_count(&path, &metadata) > 1 {
627 hardlinks.push(path);
628 }
629 }
630 }
631 deduplicate_paths(&mut hardlinks);
632 Ok(hardlinks)
633}
634
635fn workspace_scan_result<T>(
636 result: std::io::Result<T>,
637 context: impl FnOnce() -> String,
638) -> Result<Option<T>> {
639 match result {
640 Ok(value) => Ok(Some(value)),
641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
642 Err(error) => Err(error).with_context(context),
643 }
644}
645
646fn next_workspace_scan_entry(scanned: usize) -> Result<usize> {
647 let scanned = scanned
648 .checked_add(1)
649 .context("native sandbox workspace scan entry count overflowed")?;
650 if scanned > MAX_WORKSPACE_SCAN_ENTRIES {
651 bail!("native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_ENTRIES} entry scan limit");
652 }
653 Ok(scanned)
654}
655
656fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
657 if depth >= MAX_WORKSPACE_SCAN_DEPTH {
658 bail!(
659 "native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
660 path.display()
661 );
662 }
663 Ok(())
664}
665
666pub fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
669 matches!(name.to_str(), Some(".git" | "node_modules" | "target"))
670}
671
672#[cfg(unix)]
673pub fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
676 use std::os::unix::fs::MetadataExt;
677 metadata.nlink()
678}
679
680#[cfg(windows)]
681pub fn hard_link_count(path: &Path, metadata: &std::fs::Metadata) -> u64 {
684 let Ok(file) = std::fs::File::open(path) else {
685 return u64::MAX;
686 };
687 hard_link_count_for_open_file(&file, metadata)
688}
689
690#[cfg(windows)]
691pub fn hard_link_count_for_open_file<T>(file: &T, _metadata: &std::fs::Metadata) -> u64
693where
694 T: std::os::windows::io::AsRawHandle,
695{
696 use windows_sys::Win32::Storage::FileSystem::{
697 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
698 };
699
700 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
701 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
703 return u64::MAX;
704 }
705 u64::from(information.nNumberOfLinks.max(1))
706}
707
708#[cfg(unix)]
710pub fn hard_link_count_for_open_file<T>(_file: &T, metadata: &std::fs::Metadata) -> u64 {
711 use std::os::unix::fs::MetadataExt;
712 metadata.nlink()
713}
714
715#[cfg(not(any(unix, windows)))]
716pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
718 1
719}
720
721#[cfg(not(any(unix, windows)))]
722pub fn hard_link_count_for_open_file<T>(_file: &T, _metadata: &std::fs::Metadata) -> u64 {
724 1
725}
726
727fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
728 let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
729 return;
730 };
731 let root = PathBuf::from(root);
732 if !root.is_absolute() {
733 return;
734 }
735 paths.push(match suffix {
736 Some(suffix) => root.join(suffix),
737 None => root,
738 });
739}
740
741fn protected_workspace_paths(workspace: &Path) -> Vec<PathBuf> {
742 PROTECTED_WORKSPACE_DIRECTORIES
743 .iter()
744 .chain(PROTECTED_WORKSPACE_FILES)
745 .copied()
746 .map(|path| workspace.join(path))
747 .collect()
748}
749
750fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
751 let dot_git = workspace.join(".git");
752 if dot_git.is_dir() {
753 return dot_git.canonicalize().ok();
754 }
755 let source = std::fs::read_to_string(dot_git).ok()?;
756 let relative = source.trim().strip_prefix("gitdir:")?.trim();
757 let path = Path::new(relative);
758 let path = if path.is_absolute() {
759 path.to_path_buf()
760 } else {
761 workspace.join(path)
762 };
763 path.canonicalize().ok()
764}
765
766fn expand_existing_canonical_paths(paths: &mut Vec<PathBuf>) {
767 let resolved = paths
768 .iter()
769 .filter_map(|path| path.canonicalize().ok())
770 .collect::<Vec<_>>();
771 paths.extend(resolved);
772 deduplicate_paths(paths);
773}
774
775pub(super) fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
776 paths.sort();
777 paths.dedup();
778}
779
780fn remove_redundant_descendants(paths: &mut Vec<PathBuf>) {
781 deduplicate_paths(paths);
782 let candidates = paths.clone();
783 paths.retain(|path| {
784 !candidates
785 .iter()
786 .any(|ancestor| ancestor != path && path.starts_with(ancestor))
787 });
788}
789
790#[cfg(any(target_os = "linux", target_os = "macos"))]
791pub(super) fn path_ancestors(path: &Path) -> Vec<PathBuf> {
792 let mut ancestors = path
793 .parent()
794 .into_iter()
795 .flat_map(Path::ancestors)
796 .take_while(|ancestor| ancestor.parent().is_some())
797 .map(Path::to_path_buf)
798 .collect::<Vec<_>>();
799 ancestors.reverse();
800 ancestors
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[test]
808 fn child_environment_removes_runtime_injection_and_rehomes_state() {
809 let scratch = tempfile::tempdir().unwrap();
810 let explicit = HashMap::from([
811 ("SAFE_VALUE".to_string(), "visible".to_string()),
812 ("BASH_ENV".to_string(), "/tmp/attack".to_string()),
813 ("LD_PRELOAD".to_string(), "/tmp/attack.so".to_string()),
814 ]);
815 let environment = compose_child_env(Some(&explicit), scratch.path()).unwrap();
816
817 assert_eq!(
818 environment.get(OsStr::new("SAFE_VALUE")),
819 Some(&OsString::from("visible"))
820 );
821 assert!(!environment.contains_key(OsStr::new("BASH_ENV")));
822 assert!(!environment.contains_key(OsStr::new("LD_PRELOAD")));
823 assert_eq!(
824 environment.get(OsStr::new("HOME")),
825 Some(&scratch.path().as_os_str().to_os_string())
826 );
827 }
828
829 #[test]
830 fn nested_environment_files_and_hardlinks_enter_the_deny_set() {
831 let workspace = tempfile::tempdir().unwrap();
832 let scratch = tempfile::tempdir().unwrap();
833 std::fs::create_dir_all(workspace.path().join("nested")).unwrap();
834 std::fs::write(workspace.path().join("nested/.env.secret"), "secret").unwrap();
835 let outside = scratch.path().join("outside-secret");
836 std::fs::write(&outside, "outside").unwrap();
837 std::fs::hard_link(&outside, workspace.path().join("hardlink-secret")).unwrap();
838
839 let policy = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
840 let workspace = workspace.path().canonicalize().unwrap();
841
842 assert!(policy
843 .deny_read
844 .contains(&workspace.join("nested/.env.secret")));
845 assert!(policy
846 .deny_read
847 .contains(&workspace.join("hardlink-secret")));
848 assert!(policy
849 .deny_write
850 .contains(&workspace.join("hardlink-secret")));
851 }
852
853 #[cfg(any(target_os = "linux", windows))]
854 #[test]
855 fn only_protected_workspace_roots_require_directory_placeholders() {
856 let workspace = Path::new("/workspace");
857 assert!(requires_directory_placeholder(
858 workspace,
859 &workspace.join(".a3s")
860 ));
861 assert!(requires_directory_placeholder(
862 workspace,
863 &workspace.join(".GIT")
864 ));
865 assert!(!requires_directory_placeholder(
866 workspace,
867 &workspace.join(".gitmodules")
868 ));
869 assert!(!requires_directory_placeholder(
870 workspace,
871 &workspace.join(".a3s/os-auth.json")
872 ));
873 assert!(!requires_directory_placeholder(
874 workspace,
875 Path::new("/outside/.a3s")
876 ));
877 }
878
879 #[cfg(unix)]
880 #[test]
881 fn protected_workspace_symlinks_fail_closed() {
882 use std::os::unix::fs::symlink;
883
884 let workspace = tempfile::tempdir().unwrap();
885 let scratch = tempfile::tempdir().unwrap();
886 let outside = tempfile::tempdir().unwrap();
887 symlink(outside.path(), workspace.path().join(".git")).unwrap();
888
889 let error = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap_err();
890 assert!(error.to_string().contains("symbolic link"), "{error:#}");
891 }
892}