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, 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;
10/// Separate budget for rare credential-inode hunts inside package/build stores.
11/// Ordinary monorepos never enter this path because discovered secrets keep
12/// `nlink == 1`.
13const MAX_CREDENTIAL_ALIAS_SCAN_ENTRIES: usize = 5_000_000;
14
15/// Workspace-relative directories that can alter the agent, repository, or
16/// surrounding tool control plane.
17pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
18    ".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
19];
20
21/// Workspace-relative files that can alter command discovery or repository
22/// behavior without living in a protected directory.
23pub 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
34/// Return whether a normalized workspace-relative path targets protected
35/// control metadata.
36pub 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    // Normalize the excluded root before comparing it with the canonical
303    // executable path.  Temporary directories and user-provided workspaces
304    // can be reached through aliases such as `/var` -> `/private/var` on
305    // macOS; comparing unlike representations would otherwise allow a tool
306    // that physically lives inside the workspace.
307    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
407/// Resolve known host credential and authentication paths.
408pub 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
545/// Discover credential-like files inside a workspace.
546pub 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
552/// Discover source-tree files with multiple hard-link aliases.
553///
554/// Package/build stores (`node_modules`, `target`) and protected control-plane
555/// directories are skipped: bulk multi-link artifacts there blow Seatbelt
556/// profile limits, while credential aliases inside those trees are recovered
557/// separately via [`workspace_credential_hardlink_aliases`].
558pub 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
570/// Single walk that collects nested `.env*` paths and source-tree hardlinks.
571fn 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
652/// Find workspace paths that hard-link to already-discovered credential files.
653///
654/// Ordinary package/build multi-link artifacts are ignored. Only inodes that
655/// already belong to a sensitive path are collected, matching the Core local
656/// credential boundary: package-store hardlinks stay usable unless they alias
657/// a discovered credential identity.
658pub 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
805/// Return whether recursive security scans should treat a directory as a
806/// package/build store or VCS object store rather than source content.
807pub 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        // SAFETY: `file` owns a valid handle and `information` is writable.
868        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)]
891/// Return a file's hard-link count, failing conservatively on platforms where
892/// querying it requires reopening the path.
893pub 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)]
899/// Return a file's hard-link count, failing conservatively on platforms where
900/// querying it requires reopening the path.
901pub 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)]
909/// Return the hard-link count for an already-open file handle.
910pub 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    // SAFETY: `file` owns a valid handle and `information` is writable.
920    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/// Return the hard-link count for an already-open file handle.
927#[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)))]
934/// Return a conservative hard-link count on unsupported filesystems.
935pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
936    1
937}
938
939#[cfg(not(any(unix, windows)))]
940/// Return a conservative hard-link count on unsupported filesystems.
941pub 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    // Linux permits names that differ only by case even when the host's
968    // default filesystem does not. Discover those aliases explicitly so the
969    // policy remains consistent across platforms instead of protecting only
970    // the lowercase spelling of control metadata.
971    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        // Non-credential outside hardlinks in package stores stay out of the
1263        // bulk deny set (Seatbelt cannot name every Cargo object hardlink).
1264        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(&current).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}