Skip to main content

a3s_sandbox/
policy.rs

1//! Platform-neutral A3S sandbox policy construction.
2
3use 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
11/// Workspace-relative directories that can alter the agent, repository, or
12/// surrounding tool control plane.
13pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
14    ".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
15];
16
17/// Workspace-relative files that can alter command discovery or repository
18/// behavior without living in a protected directory.
19pub 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
30/// Return whether a normalized workspace-relative path targets protected
31/// control metadata.
32pub 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
387/// Resolve known host credential and authentication paths.
388pub 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
502/// Discover credential-like files inside a workspace.
503pub 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
580/// Discover workspace files with multiple hard-link aliases.
581pub 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
666/// Return whether recursive security scans should treat a directory as a
667/// package/build store rather than source content.
668pub fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
669    matches!(name.to_str(), Some(".git" | "node_modules" | "target"))
670}
671
672#[cfg(unix)]
673/// Return a file's hard-link count, failing conservatively on platforms where
674/// querying it requires reopening the path.
675pub 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)]
681/// Return a file's hard-link count, failing conservatively on platforms where
682/// querying it requires reopening the path.
683pub 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)]
691/// Return the hard-link count for an already-open file handle.
692pub 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    // SAFETY: `file` owns a valid handle and `information` is writable.
702    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/// Return the hard-link count for an already-open file handle.
709#[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)))]
716/// Return a conservative hard-link count on unsupported filesystems.
717pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
718    1
719}
720
721#[cfg(not(any(unix, windows)))]
722/// Return a conservative hard-link count on unsupported filesystems.
723pub 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}