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    // Normalize the excluded root before comparing it with the canonical
294    // executable path.  Temporary directories and user-provided workspaces
295    // can be reached through aliases such as `/var` -> `/private/var` on
296    // macOS; comparing unlike representations would otherwise allow a tool
297    // that physically lives inside the workspace.
298    let excluded_root = excluded_root.canonicalize().with_context(|| {
299        format!(
300            "failed to resolve native sandbox workspace while validating executable: {}",
301            excluded_root.display()
302        )
303    })?;
304    let binary = binary.into();
305    let candidate = if binary.components().count() == 1 {
306        find_executable_on_path(&binary, &excluded_root).ok_or_else(|| {
307            anyhow::anyhow!(
308                "required native sandbox executable was not found on PATH: {}",
309                binary.display()
310            )
311        })?
312    } else {
313        binary
314    };
315    let candidate = candidate
316        .canonicalize()
317        .with_context(|| format!("failed to resolve executable {}", candidate.display()))?;
318    if !candidate.is_file() || !is_executable(&candidate) {
319        bail!(
320            "native sandbox executable is not executable: {}",
321            candidate.display()
322        );
323    }
324    if candidate.starts_with(&excluded_root) {
325        bail!(
326            "refusing native sandbox executable from inside the active workspace: {}",
327            candidate.display()
328        );
329    }
330    Ok(candidate)
331}
332
333fn find_executable_on_path(binary: &Path, excluded_root: &Path) -> Option<PathBuf> {
334    let path = std::env::var_os("PATH")?;
335    for directory in std::env::split_paths(&path) {
336        if !directory.is_absolute() {
337            continue;
338        }
339        let candidate = directory.join(binary);
340        if executable_is_trusted(&candidate, excluded_root) {
341            return candidate.canonicalize().ok();
342        }
343        #[cfg(windows)]
344        for extension in executable_extensions() {
345            let mut name = binary.as_os_str().to_os_string();
346            name.push(extension);
347            let candidate = directory.join(name);
348            if executable_is_trusted(&candidate, excluded_root) {
349                return candidate.canonicalize().ok();
350            }
351        }
352    }
353    None
354}
355
356fn executable_is_trusted(candidate: &Path, excluded_root: &Path) -> bool {
357    if !candidate.is_file() || !is_executable(candidate) {
358        return false;
359    }
360    candidate
361        .canonicalize()
362        .is_ok_and(|resolved| !resolved.starts_with(excluded_root))
363}
364
365#[cfg(windows)]
366fn executable_extensions() -> Vec<OsString> {
367    std::env::var_os("PATHEXT")
368        .map(|value| {
369            value
370                .to_string_lossy()
371                .split(';')
372                .filter(|value| !value.is_empty())
373                .map(OsString::from)
374                .collect()
375        })
376        .unwrap_or_else(|| {
377            [".COM", ".EXE", ".BAT", ".CMD"]
378                .into_iter()
379                .map(OsString::from)
380                .collect()
381        })
382}
383
384fn is_executable(path: &Path) -> bool {
385    #[cfg(unix)]
386    {
387        use std::os::unix::fs::PermissionsExt;
388        path.metadata()
389            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
390            .unwrap_or(false)
391    }
392    #[cfg(not(unix))]
393    {
394        path.is_file()
395    }
396}
397
398/// Resolve known host credential and authentication paths.
399pub fn sensitive_paths() -> Vec<PathBuf> {
400    let mut paths = dirs::home_dir()
401        .map(|home| default_sensitive_paths(&home))
402        .unwrap_or_default();
403
404    extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
405    extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
406    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
407    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
408    for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
409        extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
410    }
411    for variable in [
412        "A3S_KIMI_DESKTOP_HOME",
413        "KIMI_DESKTOP_HOME",
414        "WORKBUDDY_CONFIG_DIR",
415        "CODEBUDDY_CONFIG_DIR",
416    ] {
417        extend_configured_secret(&mut paths, variable, None);
418    }
419    paths
420}
421
422fn read_denied_roots() -> Vec<PathBuf> {
423    let mut roots = Vec::new();
424    if let Some(home) = dirs::home_dir() {
425        roots.push(home.canonicalize().unwrap_or(home));
426    }
427    let temp = std::env::temp_dir();
428    roots.push(temp.canonicalize().unwrap_or(temp));
429    roots
430}
431
432fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
433    const TOOLCHAIN_ROOTS: &[&str] = &[
434        "CARGO_HOME",
435        "RUSTUP_HOME",
436        "GOPATH",
437        "GOROOT",
438        "GOMODCACHE",
439        "NVM_DIR",
440        "FNM_DIR",
441        "VOLTA_HOME",
442        "BUN_INSTALL",
443        "DENO_DIR",
444        "PNPM_HOME",
445        "JAVA_HOME",
446        "GRADLE_USER_HOME",
447        "MAVEN_HOME",
448        "SDKROOT",
449        "DEVELOPER_DIR",
450    ];
451
452    let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
453    for variable in TOOLCHAIN_ROOTS {
454        let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
455            continue;
456        };
457        let path = PathBuf::from(path);
458        if path.is_absolute() && path.exists() {
459            paths.push(path.canonicalize().unwrap_or(path));
460        }
461    }
462    if let Some(path) = std::env::var_os("PATH") {
463        paths.extend(std::env::split_paths(&path).filter_map(|path| {
464            if !path.is_absolute() || !path.exists() {
465                return None;
466            }
467            path.canonicalize().ok()
468        }));
469    }
470    paths
471}
472
473fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
474    [
475        ".ssh",
476        ".gnupg",
477        ".aws",
478        ".azure",
479        ".kube",
480        ".docker",
481        ".config/gcloud",
482        ".config/gh",
483        ".netrc",
484        ".npmrc",
485        ".pypirc",
486        ".cargo/credentials",
487        ".cargo/credentials.toml",
488        ".codex/auth.json",
489        ".claude/.credentials.json",
490        ".claude.json",
491        ".git-credentials",
492        ".config/git/credentials",
493        ".workbuddy",
494        "credentials/kimi-code.json",
495        ".kimi-code/credentials/kimi-code.json",
496        ".kimi/credentials/kimi-code.json",
497        ".config/kimi-desktop/daimon-share",
498        "Library/Application Support/kimi-desktop/daimon-share",
499        ".config/opencode/auth.json",
500        ".local/share/opencode/auth.json",
501        ".gemini/oauth_creds.json",
502        ".terraform.d/credentials.tfrc.json",
503        ".local/share/keyrings",
504        ".password-store",
505        ".a3s/os-auth.json",
506        "Library/Keychains",
507    ]
508    .into_iter()
509    .map(|path| home.join(path))
510    .collect()
511}
512
513/// Discover credential-like files inside a workspace.
514pub fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
515    let mut paths = [
516        ".env",
517        ".env.local",
518        ".env.development",
519        ".env.production",
520        ".env.test",
521        ".netrc",
522        ".npmrc",
523        ".pypirc",
524        ".git-credentials",
525        ".a3s/os-auth.json",
526        ".codex/auth.json",
527        ".claude/.credentials.json",
528        ".claude.json",
529    ]
530    .into_iter()
531    .map(|path| workspace.join(path))
532    .collect::<Vec<_>>();
533    paths.extend(workspace_nested_env_paths(workspace)?);
534    Ok(paths)
535}
536
537fn workspace_nested_env_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
538    let mut pending = vec![(workspace.to_path_buf(), 0usize)];
539    let mut scanned = 0usize;
540    let mut paths = Vec::new();
541
542    while let Some((directory, depth)) = pending.pop() {
543        let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
544            format!(
545                "failed to scan native sandbox workspace {}",
546                directory.display()
547            )
548        })?
549        else {
550            continue;
551        };
552        for entry in entries {
553            let Some(entry) = workspace_scan_result(entry, || {
554                format!(
555                    "failed to enumerate native sandbox workspace {}",
556                    directory.display()
557                )
558            })?
559            else {
560                continue;
561            };
562            scanned = next_workspace_scan_entry(scanned)?;
563            let path = entry.path();
564            let Some(file_type) = workspace_scan_result(entry.file_type(), || {
565                format!(
566                    "failed to inspect native sandbox workspace path {}",
567                    path.display()
568                )
569            })?
570            else {
571                continue;
572            };
573            if entry.file_name().to_str().is_some_and(|name| {
574                name.get(..4)
575                    .is_some_and(|prefix| prefix.eq_ignore_ascii_case(".env"))
576            }) {
577                paths.push(path);
578            } else if file_type.is_dir() {
579                if should_skip_workspace_scan_directory(&entry.file_name()) {
580                    continue;
581                }
582                ensure_workspace_scan_depth(depth, &path)?;
583                pending.push((path, depth + 1));
584            }
585        }
586    }
587    Ok(paths)
588}
589
590/// Discover workspace files with multiple hard-link aliases.
591pub fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
592    let mut pending = vec![(workspace.to_path_buf(), 0usize)];
593    let mut scanned = 0usize;
594    let mut hardlinks = Vec::new();
595
596    while let Some((directory, depth)) = pending.pop() {
597        let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
598            format!(
599                "failed to scan native sandbox workspace {}",
600                directory.display()
601            )
602        })?
603        else {
604            continue;
605        };
606        for entry in entries {
607            let Some(entry) = workspace_scan_result(entry, || {
608                format!(
609                    "failed to enumerate native sandbox workspace {}",
610                    directory.display()
611                )
612            })?
613            else {
614                continue;
615            };
616            scanned = next_workspace_scan_entry(scanned)?;
617            let path = entry.path();
618            let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
619                format!(
620                    "failed to inspect native sandbox workspace path {}",
621                    path.display()
622                )
623            })?
624            else {
625                continue;
626            };
627            if metadata.file_type().is_symlink() {
628                continue;
629            }
630            if metadata.is_dir() {
631                if should_skip_hardlink_scan_directory(&entry.file_name()) {
632                    continue;
633                }
634                ensure_workspace_scan_depth(depth, &path)?;
635                pending.push((path, depth + 1));
636            } else if metadata.is_file() && hard_link_count(&path, &metadata) > 1 {
637                hardlinks.push(path);
638            }
639        }
640    }
641    deduplicate_paths(&mut hardlinks);
642    Ok(hardlinks)
643}
644
645fn workspace_scan_result<T>(
646    result: std::io::Result<T>,
647    context: impl FnOnce() -> String,
648) -> Result<Option<T>> {
649    match result {
650        Ok(value) => Ok(Some(value)),
651        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
652        Err(error) => Err(error).with_context(context),
653    }
654}
655
656fn next_workspace_scan_entry(scanned: usize) -> Result<usize> {
657    let scanned = scanned
658        .checked_add(1)
659        .context("native sandbox workspace scan entry count overflowed")?;
660    if scanned > MAX_WORKSPACE_SCAN_ENTRIES {
661        bail!("native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_ENTRIES} entry scan limit");
662    }
663    Ok(scanned)
664}
665
666fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
667    if depth >= MAX_WORKSPACE_SCAN_DEPTH {
668        bail!(
669            "native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
670            path.display()
671        );
672    }
673    Ok(())
674}
675
676/// Return whether recursive security scans should treat a directory as a
677/// package/build store rather than source content.
678pub fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
679    name.to_str().is_some_and(|name| {
680        [".git", "node_modules", "target"]
681            .iter()
682            .any(|skipped| name.eq_ignore_ascii_case(skipped))
683    })
684}
685
686fn should_skip_hardlink_scan_directory(name: &OsStr) -> bool {
687    name.to_str().is_some_and(|name| {
688        PROTECTED_WORKSPACE_DIRECTORIES
689            .iter()
690            .any(|protected| name.eq_ignore_ascii_case(protected))
691    })
692}
693
694#[cfg(unix)]
695/// Return a file's hard-link count, failing conservatively on platforms where
696/// querying it requires reopening the path.
697pub fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
698    use std::os::unix::fs::MetadataExt;
699    metadata.nlink()
700}
701
702#[cfg(windows)]
703/// Return a file's hard-link count, failing conservatively on platforms where
704/// querying it requires reopening the path.
705pub fn hard_link_count(path: &Path, metadata: &std::fs::Metadata) -> u64 {
706    let Ok(file) = std::fs::File::open(path) else {
707        return u64::MAX;
708    };
709    hard_link_count_for_open_file(&file, metadata)
710}
711
712#[cfg(windows)]
713/// Return the hard-link count for an already-open file handle.
714pub fn hard_link_count_for_open_file<T>(file: &T, _metadata: &std::fs::Metadata) -> u64
715where
716    T: std::os::windows::io::AsRawHandle,
717{
718    use windows_sys::Win32::Storage::FileSystem::{
719        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
720    };
721
722    let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
723    // SAFETY: `file` owns a valid handle and `information` is writable.
724    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
725        return u64::MAX;
726    }
727    u64::from(information.nNumberOfLinks.max(1))
728}
729
730/// Return the hard-link count for an already-open file handle.
731#[cfg(unix)]
732pub fn hard_link_count_for_open_file<T>(_file: &T, metadata: &std::fs::Metadata) -> u64 {
733    use std::os::unix::fs::MetadataExt;
734    metadata.nlink()
735}
736
737#[cfg(not(any(unix, windows)))]
738/// Return a conservative hard-link count on unsupported filesystems.
739pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
740    1
741}
742
743#[cfg(not(any(unix, windows)))]
744/// Return a conservative hard-link count on unsupported filesystems.
745pub fn hard_link_count_for_open_file<T>(_file: &T, _metadata: &std::fs::Metadata) -> u64 {
746    1
747}
748
749fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
750    let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
751        return;
752    };
753    let root = PathBuf::from(root);
754    if !root.is_absolute() {
755        return;
756    }
757    paths.push(match suffix {
758        Some(suffix) => root.join(suffix),
759        None => root,
760    });
761}
762
763fn protected_workspace_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
764    let mut paths = PROTECTED_WORKSPACE_DIRECTORIES
765        .iter()
766        .chain(PROTECTED_WORKSPACE_FILES)
767        .copied()
768        .map(|path| workspace.join(path))
769        .collect::<Vec<_>>();
770
771    // Linux permits names that differ only by case even when the host's
772    // default filesystem does not. Discover those aliases explicitly so the
773    // policy remains consistent across platforms instead of protecting only
774    // the lowercase spelling of control metadata.
775    let entries = std::fs::read_dir(workspace).with_context(|| {
776        format!(
777            "failed to scan protected workspace roots {}",
778            workspace.display()
779        )
780    })?;
781    for entry in entries {
782        let entry = entry.with_context(|| {
783            format!(
784                "failed to enumerate protected workspace roots {}",
785                workspace.display()
786            )
787        })?;
788        let name = entry.file_name();
789        if PROTECTED_WORKSPACE_DIRECTORIES
790            .iter()
791            .chain(PROTECTED_WORKSPACE_FILES)
792            .any(|protected| {
793                name.to_str()
794                    .is_some_and(|name| name.eq_ignore_ascii_case(protected))
795            })
796        {
797            paths.push(entry.path());
798        }
799    }
800    Ok(paths)
801}
802
803fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
804    let dot_git = workspace.join(".git");
805    let dot_git = if dot_git.exists() {
806        dot_git
807    } else {
808        std::fs::read_dir(workspace)
809            .ok()?
810            .filter_map(Result::ok)
811            .find(|entry| {
812                entry
813                    .file_name()
814                    .to_str()
815                    .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
816            })
817            .map(|entry| entry.path())?
818    };
819    if dot_git.is_dir() {
820        return dot_git.canonicalize().ok();
821    }
822    let source = std::fs::read_to_string(dot_git).ok()?;
823    let relative = source.trim().strip_prefix("gitdir:")?.trim();
824    let path = Path::new(relative);
825    let path = if path.is_absolute() {
826        path.to_path_buf()
827    } else {
828        workspace.join(path)
829    };
830    path.canonicalize().ok()
831}
832
833fn expand_existing_canonical_paths(paths: &mut Vec<PathBuf>) {
834    let resolved = paths
835        .iter()
836        .filter_map(|path| path.canonicalize().ok())
837        .collect::<Vec<_>>();
838    paths.extend(resolved);
839    deduplicate_paths(paths);
840}
841
842pub(super) fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
843    paths.sort();
844    paths.dedup();
845}
846
847fn remove_redundant_descendants(paths: &mut Vec<PathBuf>) {
848    deduplicate_paths(paths);
849    let candidates = paths.clone();
850    paths.retain(|path| {
851        !candidates
852            .iter()
853            .any(|ancestor| ancestor != path && path.starts_with(ancestor))
854    });
855}
856
857#[cfg(any(target_os = "linux", target_os = "macos"))]
858pub(super) fn path_ancestors(path: &Path) -> Vec<PathBuf> {
859    let mut ancestors = path
860        .parent()
861        .into_iter()
862        .flat_map(Path::ancestors)
863        .take_while(|ancestor| ancestor.parent().is_some())
864        .map(Path::to_path_buf)
865        .collect::<Vec<_>>();
866    ancestors.reverse();
867    ancestors
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn child_environment_removes_runtime_injection_and_rehomes_state() {
876        let scratch = tempfile::tempdir().unwrap();
877        let explicit = HashMap::from([
878            ("SAFE_VALUE".to_string(), "visible".to_string()),
879            ("BASH_ENV".to_string(), "/tmp/attack".to_string()),
880            ("LD_PRELOAD".to_string(), "/tmp/attack.so".to_string()),
881        ]);
882        let environment = compose_child_env(Some(&explicit), scratch.path()).unwrap();
883
884        assert_eq!(
885            environment.get(OsStr::new("SAFE_VALUE")),
886            Some(&OsString::from("visible"))
887        );
888        assert!(!environment.contains_key(OsStr::new("BASH_ENV")));
889        assert!(!environment.contains_key(OsStr::new("LD_PRELOAD")));
890        assert_eq!(
891            environment.get(OsStr::new("HOME")),
892            Some(&scratch.path().as_os_str().to_os_string())
893        );
894    }
895
896    #[test]
897    fn child_environment_removes_case_insensitive_bootstrap_variables() {
898        let scratch = tempfile::tempdir().unwrap();
899        let explicit = HashMap::from([
900            ("bash_env".to_string(), "attack".to_string()),
901            ("Ld_PreLoad".to_string(), "attack.so".to_string()),
902            ("LUA_INIT_script".to_string(), "attack.lua".to_string()),
903            ("SAFE_VALUE".to_string(), "visible".to_string()),
904        ]);
905        let environment = compose_child_env(Some(&explicit), scratch.path()).unwrap();
906
907        assert!(!environment.keys().any(|key| {
908            matches!(
909                key.to_string_lossy().to_ascii_uppercase().as_str(),
910                "BASH_ENV" | "LD_PRELOAD"
911            ) || key
912                .to_string_lossy()
913                .to_ascii_uppercase()
914                .starts_with("LUA_INIT_")
915        }));
916        assert_eq!(
917            environment.get(OsStr::new("SAFE_VALUE")),
918            Some(&OsString::from("visible"))
919        );
920    }
921
922    #[test]
923    fn protected_path_matching_is_case_insensitive_and_traversal_safe() {
924        for path in [
925            ".git/config",
926            ".GIT/HEAD",
927            r".a3s\policy.acl",
928            ".mcp.json",
929            ".zshrc",
930        ] {
931            assert!(is_protected_workspace_path(path), "{path}");
932        }
933        for path in [
934            "src/.git/config",
935            "../.git/config",
936            ".gitignore",
937            "src/main.rs",
938        ] {
939            assert!(!is_protected_workspace_path(path), "{path}");
940        }
941    }
942
943    #[test]
944    fn policy_discovers_case_variant_control_metadata() {
945        let workspace = tempfile::tempdir().unwrap();
946        let scratch = tempfile::tempdir().unwrap();
947        std::fs::create_dir(workspace.path().join(".GIT")).unwrap();
948        std::fs::write(workspace.path().join(".MCP.JSON"), "control").unwrap();
949
950        let policy = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
951        let workspace = workspace.path().canonicalize().unwrap();
952        assert!(policy.deny_write.contains(&workspace.join(".GIT")));
953        assert!(policy.deny_write.contains(&workspace.join(".MCP.JSON")));
954    }
955
956    #[test]
957    fn git_worktree_pointer_is_resolved_for_case_variant_gitfiles() {
958        let parent = tempfile::tempdir().unwrap();
959        let workspace = parent.path().join("workspace");
960        let git_dir = parent.path().join("git-dir");
961        std::fs::create_dir(&workspace).unwrap();
962        std::fs::create_dir(&git_dir).unwrap();
963        std::fs::write(workspace.join(".GIT"), "gitdir: ../git-dir\n").unwrap();
964        let scratch = tempfile::tempdir().unwrap();
965
966        let policy = SandboxPolicy::for_execution(&workspace, scratch.path()).unwrap();
967        assert!(policy.deny_write.contains(&git_dir.canonicalize().unwrap()));
968    }
969
970    #[test]
971    fn nested_secret_scan_matches_case_variant_environment_files() {
972        let workspace = tempfile::tempdir().unwrap();
973        std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
974        std::fs::write(workspace.path().join("src/config/.ENV.local"), "secret").unwrap();
975
976        let paths = workspace_sensitive_paths(workspace.path()).unwrap();
977        assert!(paths.contains(&workspace.path().join("src/config/.ENV.local")));
978    }
979
980    #[test]
981    fn scan_directory_filter_handles_case_variants() {
982        for name in [".git", ".GIT", "Node_Modules", "TARGET"] {
983            assert!(should_skip_workspace_scan_directory(OsStr::new(name)));
984        }
985        assert!(!should_skip_workspace_scan_directory(OsStr::new("src")));
986    }
987
988    #[test]
989    fn nested_environment_files_and_hardlinks_enter_the_deny_set() {
990        let workspace = tempfile::tempdir().unwrap();
991        let scratch = tempfile::tempdir().unwrap();
992        std::fs::create_dir_all(workspace.path().join("nested")).unwrap();
993        std::fs::write(workspace.path().join("nested/.env.secret"), "secret").unwrap();
994        let outside = scratch.path().join("outside-secret");
995        std::fs::write(&outside, "outside").unwrap();
996        std::fs::hard_link(&outside, workspace.path().join("hardlink-secret")).unwrap();
997
998        let policy = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
999        let workspace = workspace.path().canonicalize().unwrap();
1000
1001        assert!(policy
1002            .deny_read
1003            .contains(&workspace.join("nested/.env.secret")));
1004        assert!(policy
1005            .deny_read
1006            .contains(&workspace.join("hardlink-secret")));
1007        assert!(policy
1008            .deny_write
1009            .contains(&workspace.join("hardlink-secret")));
1010    }
1011
1012    #[cfg(any(unix, windows))]
1013    #[test]
1014    fn hardlink_scan_does_not_skip_writable_dependency_trees() {
1015        let workspace = tempfile::tempdir().unwrap();
1016        let outside = tempfile::tempdir().unwrap();
1017        let source = outside.path().join("source");
1018        std::fs::write(&source, "outside").unwrap();
1019        for directory in ["node_modules", "target"] {
1020            let directory = workspace.path().join(directory);
1021            std::fs::create_dir_all(&directory).unwrap();
1022            std::fs::hard_link(&source, directory.join("linked")).unwrap();
1023        }
1024
1025        let hardlinks = workspace_hardlink_paths(workspace.path()).unwrap();
1026        assert_eq!(hardlinks.len(), 2);
1027        assert!(hardlinks
1028            .iter()
1029            .any(|path| path.ends_with("node_modules/linked")));
1030        assert!(hardlinks.iter().any(|path| path.ends_with("target/linked")));
1031    }
1032
1033    #[test]
1034    fn nested_secret_scan_skips_control_and_build_stores() {
1035        let workspace = tempfile::tempdir().unwrap();
1036        std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1037        std::fs::create_dir_all(workspace.path().join("node_modules/package")).unwrap();
1038        std::fs::create_dir_all(workspace.path().join("target/debug")).unwrap();
1039        std::fs::create_dir_all(workspace.path().join(".git")).unwrap();
1040        for path in [
1041            "src/config/.env.secret",
1042            "node_modules/package/.env.secret",
1043            "target/debug/.env.secret",
1044            ".git/.env.secret",
1045        ] {
1046            std::fs::write(workspace.path().join(path), "secret").unwrap();
1047        }
1048
1049        let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1050        assert!(paths.contains(&workspace.path().join("src/config/.env.secret")));
1051        assert!(!paths.contains(&workspace.path().join("node_modules/package/.env.secret")));
1052        assert!(!paths.contains(&workspace.path().join("target/debug/.env.secret")));
1053        assert!(!paths.contains(&workspace.path().join(".git/.env.secret")));
1054    }
1055
1056    #[cfg(unix)]
1057    #[test]
1058    fn nested_secret_scan_fails_closed_at_depth_limit() {
1059        let workspace = tempfile::tempdir().unwrap();
1060        let mut current = workspace.path().to_path_buf();
1061        for index in 0..=MAX_WORKSPACE_SCAN_DEPTH {
1062            current.push(format!("level-{index}"));
1063            std::fs::create_dir(&current).unwrap();
1064        }
1065
1066        let error = workspace_sensitive_paths(workspace.path()).unwrap_err();
1067        assert!(error.to_string().contains("depth"), "{error:#}");
1068    }
1069
1070    #[test]
1071    fn executable_resolution_rejects_workspace_tools() {
1072        let workspace = tempfile::tempdir().unwrap();
1073        let candidate = workspace.path().join("untrusted-tool");
1074        std::fs::write(&candidate, "#!/bin/sh\nexit 0\n").unwrap();
1075        #[cfg(unix)]
1076        {
1077            use std::os::unix::fs::PermissionsExt;
1078            std::fs::set_permissions(&candidate, std::fs::Permissions::from_mode(0o755)).unwrap();
1079        }
1080
1081        let error = resolve_executable(&candidate, workspace.path()).unwrap_err();
1082        assert!(error.to_string().contains("inside the active workspace"));
1083    }
1084
1085    #[cfg(any(target_os = "linux", target_os = "macos"))]
1086    #[test]
1087    fn path_ancestors_exclude_the_filesystem_root() {
1088        let ancestors = path_ancestors(Path::new("/a/b/c"));
1089        assert_eq!(ancestors, vec![PathBuf::from("/a"), PathBuf::from("/a/b")]);
1090    }
1091
1092    #[cfg(any(target_os = "linux", windows))]
1093    #[test]
1094    fn only_protected_workspace_roots_require_directory_placeholders() {
1095        let workspace = Path::new("/workspace");
1096        assert!(requires_directory_placeholder(
1097            workspace,
1098            &workspace.join(".a3s")
1099        ));
1100        assert!(requires_directory_placeholder(
1101            workspace,
1102            &workspace.join(".GIT")
1103        ));
1104        assert!(!requires_directory_placeholder(
1105            workspace,
1106            &workspace.join(".gitmodules")
1107        ));
1108        assert!(!requires_directory_placeholder(
1109            workspace,
1110            &workspace.join(".a3s/os-auth.json")
1111        ));
1112        assert!(!requires_directory_placeholder(
1113            workspace,
1114            Path::new("/outside/.a3s")
1115        ));
1116    }
1117
1118    #[cfg(unix)]
1119    #[test]
1120    fn protected_workspace_symlinks_fail_closed() {
1121        use std::os::unix::fs::symlink;
1122
1123        let workspace = tempfile::tempdir().unwrap();
1124        let scratch = tempfile::tempdir().unwrap();
1125        let outside = tempfile::tempdir().unwrap();
1126        symlink(outside.path(), workspace.path().join(".git")).unwrap();
1127
1128        let error = SandboxPolicy::for_execution(workspace.path(), scratch.path()).unwrap_err();
1129        assert!(error.to_string().contains("symbolic link"), "{error:#}");
1130    }
1131}