Skip to main content

a3s_sandbox/policy/
enforced.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.
36///
37/// Durable `/goal` loop artifacts under `.a3s/loops/` are intentionally
38/// agent-writable (ACCEPTANCE/STATE/RUN_LOG). Other `.a3s` control-plane
39/// siblings stay protected.
40pub fn is_protected_workspace_path(path: &str) -> bool {
41    let normalized = path.replace('\\', "/");
42    let mut components = normalized
43        .split('/')
44        .filter(|component| !component.is_empty() && *component != ".");
45    let Some(first) = components.next() else {
46        return false;
47    };
48    if first == ".." || components.clone().any(|component| component == "..") {
49        return false;
50    }
51
52    if first.eq_ignore_ascii_case(".a3s") {
53        return !components
54            .next()
55            .is_some_and(|second| second.eq_ignore_ascii_case("loops"));
56    }
57
58    PROTECTED_WORKSPACE_DIRECTORIES
59        .iter()
60        .any(|protected| first.eq_ignore_ascii_case(protected))
61        || PROTECTED_WORKSPACE_FILES
62            .iter()
63            .any(|protected| first.eq_ignore_ascii_case(protected))
64}
65
66#[derive(Debug)]
67pub(crate) struct EnforcedPolicy {
68    pub(crate) workspace: PathBuf,
69    pub(crate) scratch: PathBuf,
70    pub(crate) allow_read: Vec<PathBuf>,
71    pub(crate) deny_read: Vec<PathBuf>,
72    pub(crate) allow_write: Vec<PathBuf>,
73    pub(crate) deny_write: Vec<PathBuf>,
74    /// Writable carve-outs under an otherwise write-denied ancestor (e.g.
75    /// `.a3s/loops` under protected `.a3s`). Applied after deny rules.
76    pub(crate) write_exceptions: Vec<PathBuf>,
77    pub(crate) resources: crate::policy::ResourceLimits,
78    pub(crate) session_write: crate::policy::SessionWriteMode,
79    /// Loopback CONNECT mediator port when Gate 4 mediation is active.
80    pub(crate) mediator_port: Option<u16>,
81    /// Host Unix CONNECT mediator path for the Linux netns bridge (Gate 5).
82    /// When set, the guest gets `--unshare-net`, socket syscalls for the
83    /// in-netns relay, and `HTTP_PROXY` pointing at
84    /// [`crate::GUEST_HTTP_CONNECT_RELAY_PORT`].
85    pub(crate) mediator_unix_path: Option<PathBuf>,
86    /// Windows AppContainer named-pipe CONNECT path (`\\.\pipe\...`).
87    /// Guest contract is `A3S_SANDBOX_MEDIATOR_PIPE` — not `HTTP_PROXY`,
88    /// because zero-net AppContainers cannot reach loopback TCP.
89    pub(crate) mediator_pipe_name: Option<String>,
90    /// Loopback SOCKS5 mediator port when Gate 5 SOCKS mediation is active.
91    /// On Linux this is the guest relay port inside the unshared netns.
92    pub(crate) socks_mediator_port: Option<u16>,
93    /// Host Unix SOCKS5 mediator path for the Linux netns bridge. When set,
94    /// the guest gets an in-netns relay for `ALL_PROXY` traffic pointing at
95    /// [`crate::GUEST_SOCKS_CONNECT_RELAY_PORT`].
96    pub(crate) socks_mediator_unix_path: Option<PathBuf>,
97    /// Exact Unix-domain socket paths allowed for outbound connect (Gate 5).
98    pub(crate) allow_unix_sockets: Vec<PathBuf>,
99    /// Typed filesystem mount roots from the policy document (Gate 3).
100    /// Windows AppContainer ACLs grant these explicitly. User-owned PATH
101    /// binaries in `allow_read` get a separate non-inheritable execute grant;
102    /// system trees are left to their existing AppContainer ACEs.
103    pub(crate) mount_roots: Vec<PathBuf>,
104}
105
106#[derive(Debug, Clone, Copy)]
107enum OverlayKind {
108    Allow,
109    Deny,
110    Exception,
111}
112
113impl EnforcedPolicy {
114    /// Compile a validated [`crate::policy::SandboxPolicy`] into OS path sets.
115    ///
116    /// Materializes the A3S Bash baseline, then applies Exact overlays. Glob
117    /// overlays and allow-paths outside workspace/scratch fail closed.
118    pub(crate) fn compile(
119        document: &crate::policy::SandboxPolicy,
120        workspace: &Path,
121        scratch: &Path,
122        capabilities: crate::policy::BackendCapabilities,
123    ) -> Result<Self> {
124        document
125            .validate_for_backend(capabilities)
126            .context("sandbox policy is not enforceable on this backend")?;
127        let mut enforced = Self::materialize_a3s_bash_baseline(workspace, scratch)?;
128        enforced.resources = document.resources.clone();
129        enforced.session_write = document.filesystem.session_write;
130        enforced.apply_document_overlays(document)?;
131        Ok(enforced)
132    }
133
134    /// Test helper that compiles the A3S Bash baseline document.
135    #[cfg(test)]
136    pub(crate) fn for_execution(workspace: &Path, scratch: &Path) -> Result<Self> {
137        Self::compile(
138            &crate::policy::SandboxPolicy::a3s_bash_baseline(),
139            workspace,
140            scratch,
141            crate::policy::BackendCapabilities::native_gate1(),
142        )
143    }
144
145    fn materialize_a3s_bash_baseline(workspace: &Path, scratch: &Path) -> Result<Self> {
146        let workspace = workspace
147            .canonicalize()
148            .context("failed to resolve the native sandbox workspace")?;
149        let scratch = scratch
150            .canonicalize()
151            .context("failed to resolve the native sandbox scratch directory")?;
152
153        let mut protected = protected_workspace_paths(&workspace)?;
154        if let Some(git_dir) = resolved_git_dir(&workspace) {
155            protected.push(git_dir);
156        }
157        expand_existing_canonical_paths(&mut protected);
158
159        let mut sensitive = sensitive_paths();
160        let scan = scan_workspace_security(&workspace)?;
161        sensitive.extend(fixed_workspace_secret_paths(&workspace));
162        sensitive.extend(scan.nested_env);
163        sensitive.extend(scan.source_hardlinks);
164        sensitive.extend(workspace_credential_hardlink_aliases(
165            &workspace, &sensitive,
166        )?);
167        expand_existing_canonical_paths(&mut sensitive);
168
169        let mut deny_read = sensitive.clone();
170        deny_read.extend(read_denied_roots());
171        let mut allow_read = readable_tool_paths(&workspace, &scratch);
172        let allow_write = vec![workspace.clone(), scratch.clone()];
173        let mut deny_write = protected;
174        deny_write.extend(sensitive);
175        validate_denied_workspace_entries(&workspace, &deny_write)?;
176
177        // Goal Engineering writes ACCEPTANCE/STATE under `.a3s/loops`. Keep the
178        // rest of `.a3s` write-denied, but carve the loops tree back open.
179        let loops = workspace.join(".a3s").join("loops");
180        std::fs::create_dir_all(&loops).with_context(|| {
181            format!(
182                "failed to create goal-loop write carve-out {}",
183                loops.display()
184            )
185        })?;
186        let mut write_exceptions = vec![loops];
187        expand_existing_canonical_paths(&mut write_exceptions);
188
189        deduplicate_paths(&mut allow_read);
190        deduplicate_paths(&mut deny_read);
191        remove_redundant_descendants(&mut deny_write);
192        deduplicate_paths(&mut write_exceptions);
193
194        Ok(Self {
195            workspace,
196            scratch,
197            allow_read,
198            deny_read,
199            allow_write,
200            deny_write,
201            write_exceptions,
202            resources: crate::policy::ResourceLimits::default(),
203            session_write: crate::policy::SessionWriteMode::Persistent,
204            mediator_port: None,
205            mediator_unix_path: None,
206            mediator_pipe_name: None,
207            socks_mediator_port: None,
208            socks_mediator_unix_path: None,
209            allow_unix_sockets: Vec::new(),
210            mount_roots: Vec::new(),
211        })
212    }
213
214    fn apply_document_overlays(&mut self, document: &crate::policy::SandboxPolicy) -> Result<()> {
215        for rule in &document.filesystem.deny_read {
216            self.deny_read
217                .push(self.resolve_overlay_path(rule, OverlayKind::Deny)?);
218        }
219        for rule in &document.filesystem.deny_write {
220            self.deny_write
221                .push(self.resolve_overlay_path(rule, OverlayKind::Deny)?);
222        }
223        for rule in &document.filesystem.write_exceptions {
224            self.write_exceptions
225                .push(self.resolve_overlay_path(rule, OverlayKind::Exception)?);
226        }
227        for rule in &document.filesystem.allow_read {
228            let path = self.resolve_overlay_path(rule, OverlayKind::Allow)?;
229            self.ensure_within_boundary(&path, "allow_read")?;
230            self.allow_read.push(path);
231        }
232        for rule in &document.filesystem.allow_write {
233            let path = self.resolve_overlay_path(rule, OverlayKind::Allow)?;
234            self.ensure_within_boundary(&path, "allow_write")?;
235            self.allow_write.push(path);
236        }
237        for mount in &document.filesystem.mounts {
238            self.apply_mount(mount)?;
239        }
240        for rule in &document.sockets.allow_unix {
241            let path = self.resolve_overlay_path(rule, OverlayKind::Allow)?;
242            self.allow_unix_sockets.push(path);
243        }
244
245        deduplicate_paths(&mut self.allow_read);
246        deduplicate_paths(&mut self.deny_read);
247        deduplicate_paths(&mut self.allow_write);
248        remove_redundant_descendants(&mut self.deny_write);
249        deduplicate_paths(&mut self.write_exceptions);
250        deduplicate_paths(&mut self.allow_unix_sockets);
251        deduplicate_paths(&mut self.mount_roots);
252        Ok(())
253    }
254
255    fn apply_mount(&mut self, mount: &crate::policy::FilesystemMount) -> Result<()> {
256        use crate::policy::MountMode;
257        let path = self.resolve_overlay_path(&mount.root, OverlayKind::Allow)?;
258        self.mount_roots.push(path.clone());
259        match mount.mode {
260            MountMode::ReadOnly => {
261                // Outside workspace/scratch is allowed for RO knowledge trees.
262                self.allow_read.push(path.clone());
263                // If the root sits under a writable ancestor (workspace), deny
264                // writes explicitly so RO wins over the ancestor allow_write.
265                if self
266                    .allow_write
267                    .iter()
268                    .any(|writable| path.starts_with(writable))
269                {
270                    self.deny_write.push(path);
271                }
272            }
273            MountMode::ReadWrite => {
274                self.ensure_within_boundary(&path, "ReadWrite mount")?;
275                self.allow_read.push(path.clone());
276                self.allow_write.push(path);
277            }
278            MountMode::Scratch => {
279                if !path.starts_with(&self.scratch) {
280                    bail!(
281                        "Scratch mount {} must stay under session scratch {}; fail closed",
282                        path.display(),
283                        self.scratch.display()
284                    );
285                }
286                self.allow_read.push(path.clone());
287                self.allow_write.push(path);
288            }
289        }
290        Ok(())
291    }
292
293    fn resolve_overlay_path(
294        &self,
295        rule: &crate::policy::PathRule,
296        kind: OverlayKind,
297    ) -> Result<PathBuf> {
298        let value = match rule {
299            crate::policy::PathRule::Exact(value) => value,
300            crate::policy::PathRule::Glob(_) => bail!(
301                "glob path rules cannot compile into Gate 1 OS profiles; fail closed \
302                 (kind={kind:?})"
303            ),
304        };
305        let candidate = if Path::new(value).is_absolute() {
306            PathBuf::from(value)
307        } else {
308            self.workspace.join(value)
309        };
310        candidate.canonicalize().with_context(|| {
311            format!("failed to resolve policy overlay path {value} (kind={kind:?})")
312        })
313    }
314
315    fn ensure_within_boundary(&self, path: &Path, field: &str) -> Result<()> {
316        if path.starts_with(&self.workspace) || path.starts_with(&self.scratch) {
317            return Ok(());
318        }
319        bail!(
320            "policy {field} overlay {} is outside workspace/scratch and would broaden \
321             the boundary; fail closed",
322            path.display()
323        );
324    }
325
326    pub(crate) fn child_environment(
327        &self,
328        explicit: Option<&HashMap<String, String>>,
329    ) -> Result<BTreeMap<OsString, OsString>> {
330        compose_child_env(
331            explicit,
332            &self.scratch,
333            self.mediator_port,
334            self.mediator_pipe_name.as_deref(),
335            self.socks_mediator_port,
336        )
337    }
338}
339
340#[cfg(any(target_os = "linux", windows))]
341pub(crate) fn requires_directory_placeholder(workspace: &Path, path: &Path) -> bool {
342    let Ok(relative) = path.strip_prefix(workspace) else {
343        return false;
344    };
345    let mut components = relative.components();
346    let Some(component) = components.next() else {
347        return false;
348    };
349    if components.next().is_some() {
350        return false;
351    }
352    let name = component.as_os_str().to_string_lossy();
353    PROTECTED_WORKSPACE_DIRECTORIES
354        .iter()
355        .any(|protected| name.eq_ignore_ascii_case(protected))
356}
357
358fn validate_denied_workspace_entries(workspace: &Path, paths: &[PathBuf]) -> Result<()> {
359    for path in paths.iter().filter(|path| path.starts_with(workspace)) {
360        match std::fs::symlink_metadata(path) {
361            Ok(metadata) if metadata.file_type().is_symlink() => {
362                bail!(
363                    "native sandbox refuses a symbolic link at protected workspace path {}",
364                    path.display()
365                );
366            }
367            Ok(_) => {}
368            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
369            Err(error) => {
370                return Err(error).with_context(|| {
371                    format!(
372                        "failed to inspect protected workspace path {}",
373                        path.display()
374                    )
375                });
376            }
377        }
378    }
379    Ok(())
380}
381
382/// Host environment keys composed into every child before explicit entries.
383const SAFE_ENV_KEYS: &[&str] = &[
384    "PATH",
385    "USER",
386    "USERNAME",
387    "LOGNAME",
388    "SHELL",
389    "LANG",
390    "LC_ALL",
391    "LC_CTYPE",
392    "TZ",
393    "TERM",
394    "COLORTERM",
395    "NO_COLOR",
396    "CI",
397    "CARGO_HOME",
398    "RUSTUP_HOME",
399    "RUSTC_WRAPPER",
400    "GOPATH",
401    "GOROOT",
402    "GOMODCACHE",
403    "NVM_DIR",
404    "FNM_DIR",
405    "VOLTA_HOME",
406    "BUN_INSTALL",
407    "DENO_DIR",
408    "PNPM_HOME",
409    "JAVA_HOME",
410    "GRADLE_USER_HOME",
411    "MAVEN_HOME",
412    "SDKROOT",
413    "DEVELOPER_DIR",
414    "PKG_CONFIG_PATH",
415    "LIBRARY_PATH",
416    "CPATH",
417    "CC",
418    "CXX",
419    "AR",
420    "SYSTEMROOT",
421    "SYSTEMDRIVE",
422    "WINDIR",
423    "COMSPEC",
424    "PATHEXT",
425    "PSMODULEPATH",
426    "PROGRAMDATA",
427    "PROGRAMFILES",
428    "PROGRAMFILES(X86)",
429    "PROGRAMW6432",
430    "COMMONPROGRAMFILES",
431    "COMMONPROGRAMFILES(X86)",
432    "COMMONPROGRAMW6432",
433    "PROCESSOR_ARCHITECTURE",
434    "NUMBER_OF_PROCESSORS",
435    "OS",
436    "HOMEDRIVE",
437    "HOMEPATH",
438    "PUBLIC",
439    "ALLUSERSPROFILE",
440];
441
442/// Keys composed child environments force to the private scratch directory.
443const REHOME_ENV_KEYS: &[&str] = &[
444    "HOME",
445    "USERPROFILE",
446    "APPDATA",
447    "LOCALAPPDATA",
448    "TMPDIR",
449    "TMP",
450    "TEMP",
451    "XDG_CACHE_HOME",
452    "XDG_CONFIG_HOME",
453    "XDG_DATA_HOME",
454    "XDG_STATE_HOME",
455];
456
457fn compose_child_env(
458    explicit: Option<&HashMap<String, String>>,
459    scratch: &Path,
460    mediator_port: Option<u16>,
461    mediator_pipe_name: Option<&str>,
462    socks_mediator_port: Option<u16>,
463) -> Result<BTreeMap<OsString, OsString>> {
464    let mut environment = BTreeMap::new();
465    for key in SAFE_ENV_KEYS {
466        if let Some(value) = std::env::var_os(key) {
467            environment.insert(OsString::from(key), value);
468        }
469    }
470    for (key, value) in std::env::vars_os() {
471        if key.to_string_lossy().starts_with("LC_") {
472            environment.insert(key, value);
473        }
474    }
475    if let Some(explicit) = explicit {
476        for (key, value) in explicit {
477            if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') {
478                bail!("invalid explicit command environment entry: {key:?}");
479            }
480            environment.insert(OsString::from(key), OsString::from(value));
481        }
482    }
483    remove_bootstrap_injection_variables(&mut environment);
484    scrub_proxy_environment(&mut environment);
485    environment.retain(|key, _| {
486        let key = key.to_string_lossy();
487        !key.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE")
488            && !key.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE_HANDLE")
489    });
490    if let Some(port) = mediator_port {
491        let proxy = OsString::from(format!("http://127.0.0.1:{port}"));
492        for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] {
493            environment.insert(OsString::from(key), proxy.clone());
494        }
495        // When SOCKS is also active, ALL_PROXY points at SOCKS below.
496        if socks_mediator_port.is_none() {
497            for key in ["ALL_PROXY", "all_proxy"] {
498                environment.insert(OsString::from(key), proxy.clone());
499            }
500        }
501        // Force tools through the host mediator; bypass lists would defeat Gate 4.
502        environment.insert(OsString::from("NO_PROXY"), OsString::from(""));
503        environment.insert(OsString::from("no_proxy"), OsString::from(""));
504    }
505    if let Some(pipe_name) = mediator_pipe_name {
506        // Windows AppContainer bridge: named pipe only. HTTP_PROXY would imply
507        // guest loopback TCP, which zero-net AppContainers cannot use.
508        environment.insert(
509            OsString::from("A3S_SANDBOX_MEDIATOR_PIPE"),
510            OsString::from(pipe_name),
511        );
512    }
513    if let Some(port) = socks_mediator_port {
514        let proxy = OsString::from(format!("socks5://127.0.0.1:{port}"));
515        for key in ["ALL_PROXY", "all_proxy"] {
516            environment.insert(OsString::from(key), proxy.clone());
517        }
518        environment.insert(OsString::from("NO_PROXY"), OsString::from(""));
519        environment.insert(OsString::from("no_proxy"), OsString::from(""));
520    }
521
522    let scratch = scratch.as_os_str().to_os_string();
523    for key in REHOME_ENV_KEYS {
524        environment.insert(OsString::from(key), scratch.clone());
525    }
526    Ok(environment)
527}
528
529/// Proxy keys scrubbed from child environments; mediation re-adds its own.
530const PROXY_ENV_KEYS: &[&str] = &[
531    "HTTP_PROXY",
532    "HTTPS_PROXY",
533    "ALL_PROXY",
534    "NO_PROXY",
535    "http_proxy",
536    "https_proxy",
537    "all_proxy",
538    "no_proxy",
539    "FTP_PROXY",
540    "ftp_proxy",
541];
542
543fn scrub_proxy_environment(environment: &mut BTreeMap<OsString, OsString>) {
544    environment.retain(|key, _| {
545        let key = key.to_string_lossy();
546        !PROXY_ENV_KEYS
547            .iter()
548            .any(|blocked| key.eq_ignore_ascii_case(blocked))
549    });
550}
551
552/// Shell/runtime bootstrap keys stripped from child environments so the
553/// command string cannot inject code through them.
554const BOOTSTRAP_INJECTION_KEYS: &[&str] = &[
555    "BASH_ENV",
556    "ENV",
557    "NODE_OPTIONS",
558    "NODE_PATH",
559    "PYTHONHOME",
560    "PYTHONPATH",
561    "PYTHONSTARTUP",
562    "PYTHONINSPECT",
563    "RUBYOPT",
564    "RUBYLIB",
565    "PERL5OPT",
566    "PERL5LIB",
567    "LUA_INIT",
568    "JAVA_TOOL_OPTIONS",
569    "JDK_JAVA_OPTIONS",
570    "_JAVA_OPTIONS",
571    "LD_PRELOAD",
572    "LD_LIBRARY_PATH",
573    "DYLD_INSERT_LIBRARIES",
574    "DYLD_LIBRARY_PATH",
575];
576
577fn remove_bootstrap_injection_variables(environment: &mut BTreeMap<OsString, OsString>) {
578    environment.retain(|key, _| {
579        let key = key.to_string_lossy();
580        !BOOTSTRAP_INJECTION_KEYS
581            .iter()
582            .any(|blocked| key.eq_ignore_ascii_case(blocked))
583            && !key.to_ascii_uppercase().starts_with("LUA_INIT_")
584    });
585}
586
587/// Environment names a secret entry may never occupy: keys the composed child
588/// environment forcibly overwrites or strips, plus toolchain bootstrap keys a
589/// sentinel would silently break. Gate 8 fail-closed rule.
590pub(crate) fn secret_env_name_is_reserved(name: &str) -> bool {
591    [
592        REHOME_ENV_KEYS,
593        PROXY_ENV_KEYS,
594        SAFE_ENV_KEYS,
595        BOOTSTRAP_INJECTION_KEYS,
596    ]
597    .into_iter()
598    .any(|group| group.iter().any(|key| name.eq_ignore_ascii_case(key)))
599        || name.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE")
600        || name.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE_HANDLE")
601}
602
603pub(crate) fn resolve_executable(
604    binary: impl Into<PathBuf>,
605    excluded_root: &Path,
606) -> Result<PathBuf> {
607    // Normalize the excluded root before comparing it with the canonical
608    // executable path.  Temporary directories and user-provided workspaces
609    // can be reached through aliases such as `/var` -> `/private/var` on
610    // macOS; comparing unlike representations would otherwise allow a tool
611    // that physically lives inside the workspace.
612    let excluded_root = excluded_root.canonicalize().with_context(|| {
613        format!(
614            "failed to resolve native sandbox workspace while validating executable: {}",
615            excluded_root.display()
616        )
617    })?;
618    let binary = binary.into();
619    let candidate = if binary.components().count() == 1 {
620        find_executable_on_path(&binary, &excluded_root).ok_or_else(|| {
621            anyhow::anyhow!(
622                "required native sandbox executable was not found on PATH: {}",
623                binary.display()
624            )
625        })?
626    } else {
627        binary
628    };
629    let candidate = candidate
630        .canonicalize()
631        .with_context(|| format!("failed to resolve executable {}", candidate.display()))?;
632    if !candidate.is_file() || !is_executable(&candidate) {
633        bail!(
634            "native sandbox executable is not executable: {}",
635            candidate.display()
636        );
637    }
638    if candidate.starts_with(&excluded_root) {
639        bail!(
640            "refusing native sandbox executable from inside the active workspace: {}",
641            candidate.display()
642        );
643    }
644    Ok(candidate)
645}
646
647fn find_executable_on_path(binary: &Path, excluded_root: &Path) -> Option<PathBuf> {
648    let path = std::env::var_os("PATH")?;
649    for directory in std::env::split_paths(&path) {
650        if !directory.is_absolute() {
651            continue;
652        }
653        let candidate = directory.join(binary);
654        if executable_is_trusted(&candidate, excluded_root) {
655            return candidate.canonicalize().ok();
656        }
657        #[cfg(windows)]
658        for extension in executable_extensions() {
659            let mut name = binary.as_os_str().to_os_string();
660            name.push(extension);
661            let candidate = directory.join(name);
662            if executable_is_trusted(&candidate, excluded_root) {
663                return candidate.canonicalize().ok();
664            }
665        }
666    }
667    None
668}
669
670fn executable_is_trusted(candidate: &Path, excluded_root: &Path) -> bool {
671    if !candidate.is_file() || !is_executable(candidate) {
672        return false;
673    }
674    candidate
675        .canonicalize()
676        .is_ok_and(|resolved| !resolved.starts_with(excluded_root))
677}
678
679#[cfg(windows)]
680fn executable_extensions() -> Vec<OsString> {
681    std::env::var_os("PATHEXT")
682        .map(|value| {
683            value
684                .to_string_lossy()
685                .split(';')
686                .filter(|value| !value.is_empty())
687                .map(OsString::from)
688                .collect()
689        })
690        .unwrap_or_else(|| {
691            [".COM", ".EXE", ".BAT", ".CMD"]
692                .into_iter()
693                .map(OsString::from)
694                .collect()
695        })
696}
697
698fn is_executable(path: &Path) -> bool {
699    #[cfg(unix)]
700    {
701        use std::os::unix::fs::PermissionsExt;
702        path.metadata()
703            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
704            .unwrap_or(false)
705    }
706    #[cfg(not(unix))]
707    {
708        path.is_file()
709    }
710}
711
712/// Resolve known host credential and authentication paths.
713pub fn sensitive_paths() -> Vec<PathBuf> {
714    let mut paths = dirs::home_dir()
715        .map(|home| default_sensitive_paths(&home))
716        .unwrap_or_default();
717
718    extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
719    extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
720    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
721    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
722    for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
723        extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
724    }
725    for variable in [
726        "A3S_KIMI_DESKTOP_HOME",
727        "KIMI_DESKTOP_HOME",
728        "WORKBUDDY_CONFIG_DIR",
729        "CODEBUDDY_CONFIG_DIR",
730    ] {
731        extend_configured_secret(&mut paths, variable, None);
732    }
733    paths
734}
735
736fn read_denied_roots() -> Vec<PathBuf> {
737    let mut roots = Vec::new();
738    if let Some(home) = dirs::home_dir() {
739        roots.push(home.canonicalize().unwrap_or(home));
740    }
741    let temp = std::env::temp_dir();
742    roots.push(temp.canonicalize().unwrap_or(temp));
743    roots
744}
745
746fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
747    const TOOLCHAIN_ROOTS: &[&str] = &[
748        "CARGO_HOME",
749        "RUSTUP_HOME",
750        "GOPATH",
751        "GOROOT",
752        "GOMODCACHE",
753        "NVM_DIR",
754        "FNM_DIR",
755        "VOLTA_HOME",
756        "BUN_INSTALL",
757        "DENO_DIR",
758        "PNPM_HOME",
759        "JAVA_HOME",
760        "GRADLE_USER_HOME",
761        "MAVEN_HOME",
762        "SDKROOT",
763        "DEVELOPER_DIR",
764    ];
765
766    let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
767    for variable in TOOLCHAIN_ROOTS {
768        let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
769            continue;
770        };
771        let path = PathBuf::from(path);
772        if path.is_absolute() && path.exists() {
773            paths.push(path.canonicalize().unwrap_or(path));
774        }
775    }
776    if let Some(path) = std::env::var_os("PATH") {
777        paths.extend(std::env::split_paths(&path).filter_map(|path| {
778            if !path.is_absolute() || !path.exists() {
779                return None;
780            }
781            path.canonicalize().ok()
782        }));
783    }
784    paths
785}
786
787fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
788    [
789        ".ssh",
790        ".gnupg",
791        ".aws",
792        ".azure",
793        ".kube",
794        ".docker",
795        ".config/gcloud",
796        ".config/gh",
797        ".netrc",
798        ".npmrc",
799        ".pypirc",
800        ".cargo/credentials",
801        ".cargo/credentials.toml",
802        ".codex/auth.json",
803        ".claude/.credentials.json",
804        ".claude.json",
805        ".git-credentials",
806        ".config/git/credentials",
807        ".workbuddy",
808        ".workbuddy-ai",
809        "credentials/kimi-code.json",
810        ".kimi-code/credentials/kimi-code.json",
811        ".kimi/credentials/kimi-code.json",
812        ".config/kimi-desktop/daimon-share",
813        "Library/Application Support/kimi-desktop/daimon-share",
814        ".config/opencode/auth.json",
815        ".local/share/opencode/auth.json",
816        ".gemini/oauth_creds.json",
817        ".terraform.d/credentials.tfrc.json",
818        ".local/share/keyrings",
819        ".password-store",
820        ".a3s/os-auth.json",
821        "Library/Keychains",
822    ]
823    .into_iter()
824    .map(|path| home.join(path))
825    .collect()
826}
827
828const FIXED_WORKSPACE_SECRET_FILES: &[&str] = &[
829    ".env",
830    ".env.local",
831    ".env.development",
832    ".env.production",
833    ".env.test",
834    ".netrc",
835    ".npmrc",
836    ".pypirc",
837    ".git-credentials",
838    ".a3s/os-auth.json",
839    ".codex/auth.json",
840    ".claude/.credentials.json",
841    ".claude.json",
842];
843
844fn fixed_workspace_secret_paths(workspace: &Path) -> Vec<PathBuf> {
845    FIXED_WORKSPACE_SECRET_FILES
846        .iter()
847        .map(|path| workspace.join(path))
848        .collect()
849}
850
851/// Discover credential-like files inside a workspace.
852pub fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
853    let mut paths = fixed_workspace_secret_paths(workspace);
854    paths.extend(scan_workspace_security(workspace)?.nested_env);
855    Ok(paths)
856}
857
858/// Discover source-tree files with multiple hard-link aliases.
859///
860/// Package/build stores (`node_modules`, `target`) and protected control-plane
861/// directories are skipped: bulk multi-link artifacts there blow Seatbelt
862/// profile limits, while credential aliases inside those trees are recovered
863/// separately via [`workspace_credential_hardlink_aliases`].
864pub fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
865    let mut hardlinks = scan_workspace_security(workspace)?.source_hardlinks;
866    deduplicate_paths(&mut hardlinks);
867    Ok(hardlinks)
868}
869
870#[derive(Debug, Default)]
871struct WorkspaceSecurityScan {
872    nested_env: Vec<PathBuf>,
873    source_hardlinks: Vec<PathBuf>,
874}
875
876/// Single walk that collects nested `.env*` paths and source-tree hardlinks.
877fn scan_workspace_security(workspace: &Path) -> Result<WorkspaceSecurityScan> {
878    let mut pending = vec![(workspace.to_path_buf(), 0usize, true)];
879    let mut scanned = 0usize;
880    let mut scan = WorkspaceSecurityScan::default();
881
882    while let Some((directory, depth, collect_hardlinks)) = pending.pop() {
883        let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
884            format!(
885                "failed to scan native sandbox workspace {}",
886                directory.display()
887            )
888        })?
889        else {
890            continue;
891        };
892        for entry in entries {
893            let Some(entry) = workspace_scan_result(entry, || {
894                format!(
895                    "failed to enumerate native sandbox workspace {}",
896                    directory.display()
897                )
898            })?
899            else {
900                continue;
901            };
902            scanned = next_workspace_scan_entry(scanned, MAX_WORKSPACE_SCAN_ENTRIES)?;
903            let path = entry.path();
904            let file_name = entry.file_name();
905            let Some(file_type) = workspace_scan_result(entry.file_type(), || {
906                format!(
907                    "failed to inspect native sandbox workspace path {}",
908                    path.display()
909                )
910            })?
911            else {
912                continue;
913            };
914
915            if file_name.to_str().is_some_and(|name| {
916                name.get(..4)
917                    .is_some_and(|prefix| prefix.eq_ignore_ascii_case(".env"))
918            }) {
919                scan.nested_env.push(path.clone());
920            }
921
922            if file_type.is_symlink() {
923                continue;
924            }
925            if file_type.is_dir() {
926                if should_skip_workspace_scan_directory(&file_name) {
927                    continue;
928                }
929                ensure_workspace_scan_depth(depth, &path)?;
930                let child_collect_hardlinks =
931                    collect_hardlinks && !is_protected_workspace_directory(&file_name);
932                pending.push((path, depth + 1, child_collect_hardlinks));
933                continue;
934            }
935            if !collect_hardlinks || !file_type.is_file() {
936                continue;
937            }
938            let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
939                format!(
940                    "failed to inspect native sandbox workspace path {}",
941                    path.display()
942                )
943            })?
944            else {
945                continue;
946            };
947            if metadata.file_type().is_symlink() || !metadata.is_file() {
948                continue;
949            }
950            if hard_link_count(&path, &metadata) > 1 {
951                scan.source_hardlinks.push(path);
952            }
953        }
954    }
955    Ok(scan)
956}
957
958/// Find workspace paths that hard-link to already-discovered credential files.
959///
960/// Ordinary package/build multi-link artifacts are ignored. Only inodes that
961/// already belong to a sensitive path are collected, matching the Core local
962/// credential boundary: package-store hardlinks stay usable unless they alias
963/// a discovered credential identity.
964pub fn workspace_credential_hardlink_aliases(
965    workspace: &Path,
966    sensitive: &[PathBuf],
967) -> Result<Vec<PathBuf>> {
968    let mut wanted = HashSet::new();
969    for path in sensitive {
970        let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(path), || {
971            format!(
972                "failed to inspect native sandbox credential path {}",
973                path.display()
974            )
975        })?
976        else {
977            continue;
978        };
979        if metadata.file_type().is_symlink() || !metadata.is_file() {
980            continue;
981        }
982        if hard_link_count(path, &metadata) <= 1 {
983            continue;
984        }
985        let Some(identity) = FileIdentity::from_path(path, &metadata) else {
986            continue;
987        };
988        wanted.insert(identity);
989    }
990    if wanted.is_empty() {
991        return Ok(Vec::new());
992    }
993
994    let mut pending = vec![(workspace.to_path_buf(), 0usize, false)];
995    let mut scanned = 0usize;
996    let mut aliases = Vec::new();
997    let sensitive_set: HashSet<&Path> = sensitive.iter().map(PathBuf::as_path).collect();
998
999    while let Some((directory, depth, in_package_store)) = pending.pop() {
1000        let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
1001            format!(
1002                "failed to scan native sandbox package stores under {}",
1003                directory.display()
1004            )
1005        })?
1006        else {
1007            continue;
1008        };
1009        for entry in entries {
1010            let Some(entry) = workspace_scan_result(entry, || {
1011                format!(
1012                    "failed to enumerate native sandbox package stores under {}",
1013                    directory.display()
1014                )
1015            })?
1016            else {
1017                continue;
1018            };
1019            scanned = next_workspace_scan_entry(scanned, MAX_CREDENTIAL_ALIAS_SCAN_ENTRIES)?;
1020            let path = entry.path();
1021            let file_name = entry.file_name();
1022            let Some(file_type) = workspace_scan_result(entry.file_type(), || {
1023                format!(
1024                    "failed to inspect native sandbox package-store path {}",
1025                    path.display()
1026                )
1027            })?
1028            else {
1029                continue;
1030            };
1031            if file_type.is_symlink() {
1032                continue;
1033            }
1034            if file_type.is_dir() {
1035                if !in_package_store && is_git_directory(&file_name) {
1036                    continue;
1037                }
1038                let child_in_store =
1039                    in_package_store || is_package_or_build_store_directory(&file_name);
1040                if !child_in_store && is_protected_workspace_directory(&file_name) {
1041                    continue;
1042                }
1043                ensure_workspace_scan_depth(depth, &path)?;
1044                pending.push((path, depth + 1, child_in_store));
1045                continue;
1046            }
1047            if !in_package_store || !file_type.is_file() {
1048                continue;
1049            }
1050            if sensitive_set.contains(path.as_path()) {
1051                continue;
1052            }
1053            let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
1054                format!(
1055                    "failed to inspect native sandbox package-store path {}",
1056                    path.display()
1057                )
1058            })?
1059            else {
1060                continue;
1061            };
1062            if metadata.file_type().is_symlink() || !metadata.is_file() {
1063                continue;
1064            }
1065            if hard_link_count(&path, &metadata) <= 1 {
1066                continue;
1067            }
1068            let Some(identity) = FileIdentity::from_path(&path, &metadata) else {
1069                continue;
1070            };
1071            if wanted.contains(&identity) {
1072                aliases.push(path);
1073            }
1074        }
1075    }
1076    deduplicate_paths(&mut aliases);
1077    Ok(aliases)
1078}
1079
1080fn workspace_scan_result<T>(
1081    result: std::io::Result<T>,
1082    context: impl FnOnce() -> String,
1083) -> Result<Option<T>> {
1084    match result {
1085        Ok(value) => Ok(Some(value)),
1086        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1087        Err(error) => Err(error).with_context(context),
1088    }
1089}
1090
1091fn next_workspace_scan_entry(scanned: usize, limit: usize) -> Result<usize> {
1092    let scanned = scanned
1093        .checked_add(1)
1094        .context("native sandbox workspace scan entry count overflowed")?;
1095    if scanned > limit {
1096        bail!("native sandbox workspace exceeds the {limit} entry scan limit");
1097    }
1098    Ok(scanned)
1099}
1100
1101fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
1102    if depth >= MAX_WORKSPACE_SCAN_DEPTH {
1103        bail!(
1104            "native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
1105            path.display()
1106        );
1107    }
1108    Ok(())
1109}
1110
1111/// Return whether recursive security scans should treat a directory as a
1112/// package/build store or VCS object store rather than source content.
1113pub fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
1114    is_git_directory(name) || is_package_or_build_store_directory(name)
1115}
1116
1117fn is_package_or_build_store_directory(name: &OsStr) -> bool {
1118    name.to_str().is_some_and(|name| {
1119        ["node_modules", "target"]
1120            .iter()
1121            .any(|skipped| name.eq_ignore_ascii_case(skipped))
1122    })
1123}
1124
1125fn is_git_directory(name: &OsStr) -> bool {
1126    name.to_str()
1127        .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
1128}
1129
1130fn is_protected_workspace_directory(name: &OsStr) -> bool {
1131    name.to_str().is_some_and(|name| {
1132        PROTECTED_WORKSPACE_DIRECTORIES
1133            .iter()
1134            .any(|protected| name.eq_ignore_ascii_case(protected))
1135    })
1136}
1137
1138#[cfg(unix)]
1139#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
1140struct FileIdentity {
1141    device: u64,
1142    inode: u64,
1143}
1144
1145#[cfg(unix)]
1146impl FileIdentity {
1147    fn from_path(_path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
1148        use std::os::unix::fs::MetadataExt;
1149        Some(Self {
1150            device: metadata.dev(),
1151            inode: metadata.ino(),
1152        })
1153    }
1154}
1155
1156#[cfg(windows)]
1157#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
1158struct FileIdentity {
1159    volume: u32,
1160    index: u64,
1161}
1162
1163#[cfg(windows)]
1164impl FileIdentity {
1165    fn from_path(path: &Path, _metadata: &std::fs::Metadata) -> Option<Self> {
1166        use std::os::windows::io::AsRawHandle;
1167        use windows_sys::Win32::Storage::FileSystem::{
1168            GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1169        };
1170
1171        let file = std::fs::File::open(path).ok()?;
1172        let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
1173        // SAFETY: `file` owns a valid handle and `information` is writable.
1174        if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
1175            return None;
1176        }
1177        Some(Self {
1178            volume: information.dwVolumeSerialNumber,
1179            index: (u64::from(information.nFileIndexHigh) << 32)
1180                | u64::from(information.nFileIndexLow),
1181        })
1182    }
1183}
1184
1185#[cfg(not(any(unix, windows)))]
1186#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
1187struct FileIdentity;
1188
1189#[cfg(not(any(unix, windows)))]
1190impl FileIdentity {
1191    fn from_path(_path: &Path, _metadata: &std::fs::Metadata) -> Option<Self> {
1192        None
1193    }
1194}
1195
1196#[cfg(unix)]
1197/// Return a file's hard-link count, failing conservatively on platforms where
1198/// querying it requires reopening the path.
1199pub fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
1200    use std::os::unix::fs::MetadataExt;
1201    metadata.nlink()
1202}
1203
1204#[cfg(windows)]
1205/// Return a file's hard-link count, failing conservatively on platforms where
1206/// querying it requires reopening the path.
1207pub fn hard_link_count(path: &Path, metadata: &std::fs::Metadata) -> u64 {
1208    let Ok(file) = std::fs::File::open(path) else {
1209        return u64::MAX;
1210    };
1211    hard_link_count_for_open_file(&file, metadata)
1212}
1213
1214#[cfg(windows)]
1215/// Return the hard-link count for an already-open file handle.
1216pub fn hard_link_count_for_open_file<T>(file: &T, _metadata: &std::fs::Metadata) -> u64
1217where
1218    T: std::os::windows::io::AsRawHandle,
1219{
1220    use windows_sys::Win32::Storage::FileSystem::{
1221        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1222    };
1223
1224    let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
1225    // SAFETY: `file` owns a valid handle and `information` is writable.
1226    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
1227        return u64::MAX;
1228    }
1229    u64::from(information.nNumberOfLinks.max(1))
1230}
1231
1232/// Return the hard-link count for an already-open file handle.
1233#[cfg(unix)]
1234pub fn hard_link_count_for_open_file<T>(_file: &T, metadata: &std::fs::Metadata) -> u64 {
1235    use std::os::unix::fs::MetadataExt;
1236    metadata.nlink()
1237}
1238
1239#[cfg(not(any(unix, windows)))]
1240/// Return a conservative hard-link count on unsupported filesystems.
1241pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
1242    1
1243}
1244
1245#[cfg(not(any(unix, windows)))]
1246/// Return a conservative hard-link count on unsupported filesystems.
1247pub fn hard_link_count_for_open_file<T>(_file: &T, _metadata: &std::fs::Metadata) -> u64 {
1248    1
1249}
1250
1251fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
1252    let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
1253        return;
1254    };
1255    let root = PathBuf::from(root);
1256    if !root.is_absolute() {
1257        return;
1258    }
1259    paths.push(match suffix {
1260        Some(suffix) => root.join(suffix),
1261        None => root,
1262    });
1263}
1264
1265fn protected_workspace_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
1266    let mut paths = PROTECTED_WORKSPACE_DIRECTORIES
1267        .iter()
1268        .chain(PROTECTED_WORKSPACE_FILES)
1269        .copied()
1270        .map(|path| workspace.join(path))
1271        .collect::<Vec<_>>();
1272
1273    // Linux permits names that differ only by case even when the host's
1274    // default filesystem does not. Discover those aliases explicitly so the
1275    // policy remains consistent across platforms instead of protecting only
1276    // the lowercase spelling of control metadata.
1277    let entries = std::fs::read_dir(workspace).with_context(|| {
1278        format!(
1279            "failed to scan protected workspace roots {}",
1280            workspace.display()
1281        )
1282    })?;
1283    for entry in entries {
1284        let entry = entry.with_context(|| {
1285            format!(
1286                "failed to enumerate protected workspace roots {}",
1287                workspace.display()
1288            )
1289        })?;
1290        let name = entry.file_name();
1291        if PROTECTED_WORKSPACE_DIRECTORIES
1292            .iter()
1293            .chain(PROTECTED_WORKSPACE_FILES)
1294            .any(|protected| {
1295                name.to_str()
1296                    .is_some_and(|name| name.eq_ignore_ascii_case(protected))
1297            })
1298        {
1299            paths.push(entry.path());
1300        }
1301    }
1302    Ok(paths)
1303}
1304
1305fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
1306    let dot_git = workspace.join(".git");
1307    let dot_git = if dot_git.exists() {
1308        dot_git
1309    } else {
1310        std::fs::read_dir(workspace)
1311            .ok()?
1312            .filter_map(Result::ok)
1313            .find(|entry| {
1314                entry
1315                    .file_name()
1316                    .to_str()
1317                    .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
1318            })
1319            .map(|entry| entry.path())?
1320    };
1321    if dot_git.is_dir() {
1322        return dot_git.canonicalize().ok();
1323    }
1324    let source = std::fs::read_to_string(dot_git).ok()?;
1325    let relative = source.trim().strip_prefix("gitdir:")?.trim();
1326    let path = Path::new(relative);
1327    let path = if path.is_absolute() {
1328        path.to_path_buf()
1329    } else {
1330        workspace.join(path)
1331    };
1332    path.canonicalize().ok()
1333}
1334
1335fn expand_existing_canonical_paths(paths: &mut Vec<PathBuf>) {
1336    let resolved = paths
1337        .iter()
1338        .filter_map(|path| path.canonicalize().ok())
1339        .collect::<Vec<_>>();
1340    paths.extend(resolved);
1341    deduplicate_paths(paths);
1342}
1343
1344pub(crate) fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
1345    paths.sort();
1346    paths.dedup();
1347}
1348
1349fn remove_redundant_descendants(paths: &mut Vec<PathBuf>) {
1350    deduplicate_paths(paths);
1351    let candidates = paths.clone();
1352    paths.retain(|path| {
1353        !candidates
1354            .iter()
1355            .any(|ancestor| ancestor != path && path.starts_with(ancestor))
1356    });
1357}
1358
1359#[cfg(any(target_os = "linux", target_os = "macos"))]
1360pub(crate) fn path_ancestors(path: &Path) -> Vec<PathBuf> {
1361    let mut ancestors = path
1362        .parent()
1363        .into_iter()
1364        .flat_map(Path::ancestors)
1365        .take_while(|ancestor| ancestor.parent().is_some())
1366        .map(Path::to_path_buf)
1367        .collect::<Vec<_>>();
1368    ancestors.reverse();
1369    ancestors
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375
1376    #[test]
1377    fn child_environment_removes_runtime_injection_and_rehomes_state() {
1378        let scratch = tempfile::tempdir().unwrap();
1379        let explicit = HashMap::from([
1380            ("SAFE_VALUE".to_string(), "visible".to_string()),
1381            ("BASH_ENV".to_string(), "/tmp/attack".to_string()),
1382            ("LD_PRELOAD".to_string(), "/tmp/attack.so".to_string()),
1383        ]);
1384        let environment =
1385            compose_child_env(Some(&explicit), scratch.path(), None, None, None).unwrap();
1386
1387        assert_eq!(
1388            environment.get(OsStr::new("SAFE_VALUE")),
1389            Some(&OsString::from("visible"))
1390        );
1391        assert!(!environment.contains_key(OsStr::new("BASH_ENV")));
1392        assert!(!environment.contains_key(OsStr::new("LD_PRELOAD")));
1393        assert_eq!(
1394            environment.get(OsStr::new("HOME")),
1395            Some(&scratch.path().as_os_str().to_os_string())
1396        );
1397    }
1398
1399    #[test]
1400    fn child_environment_removes_case_insensitive_bootstrap_variables() {
1401        let scratch = tempfile::tempdir().unwrap();
1402        let explicit = HashMap::from([
1403            ("bash_env".to_string(), "attack".to_string()),
1404            ("Ld_PreLoad".to_string(), "attack.so".to_string()),
1405            ("LUA_INIT_script".to_string(), "attack.lua".to_string()),
1406            ("SAFE_VALUE".to_string(), "visible".to_string()),
1407        ]);
1408        let environment =
1409            compose_child_env(Some(&explicit), scratch.path(), None, None, None).unwrap();
1410
1411        assert!(!environment.keys().any(|key| {
1412            matches!(
1413                key.to_string_lossy().to_ascii_uppercase().as_str(),
1414                "BASH_ENV" | "LD_PRELOAD"
1415            ) || key
1416                .to_string_lossy()
1417                .to_ascii_uppercase()
1418                .starts_with("LUA_INIT_")
1419        }));
1420        assert_eq!(
1421            environment.get(OsStr::new("SAFE_VALUE")),
1422            Some(&OsString::from("visible"))
1423        );
1424    }
1425
1426    #[test]
1427    fn child_environment_mediator_overwrites_explicit_proxy_bypass() {
1428        let scratch = tempfile::tempdir().unwrap();
1429        let explicit = HashMap::from([
1430            ("NO_PROXY".to_string(), "*".to_string()),
1431            (
1432                "HTTPS_PROXY".to_string(),
1433                "http://evil.example:9".to_string(),
1434            ),
1435            ("FTP_PROXY".to_string(), "http://evil.example:9".to_string()),
1436            (
1437                "ALL_PROXY".to_string(),
1438                "socks5://evil.example:9".to_string(),
1439            ),
1440        ]);
1441        let environment =
1442            compose_child_env(Some(&explicit), scratch.path(), Some(18080), None, None).unwrap();
1443        assert_eq!(
1444            environment.get(OsStr::new("HTTPS_PROXY")),
1445            Some(&OsString::from("http://127.0.0.1:18080"))
1446        );
1447        assert_eq!(
1448            environment.get(OsStr::new("ALL_PROXY")),
1449            Some(&OsString::from("http://127.0.0.1:18080"))
1450        );
1451        assert_eq!(
1452            environment.get(OsStr::new("NO_PROXY")),
1453            Some(&OsString::from(""))
1454        );
1455        assert!(!environment.contains_key(OsStr::new("FTP_PROXY")));
1456    }
1457
1458    #[test]
1459    fn child_environment_socks_mediator_sets_all_proxy_only() {
1460        let scratch = tempfile::tempdir().unwrap();
1461        let environment = compose_child_env(None, scratch.path(), None, None, Some(19090)).unwrap();
1462        assert_eq!(
1463            environment.get(OsStr::new("ALL_PROXY")),
1464            Some(&OsString::from("socks5://127.0.0.1:19090"))
1465        );
1466        assert!(!environment.contains_key(OsStr::new("HTTPS_PROXY")));
1467        assert_eq!(
1468            environment.get(OsStr::new("NO_PROXY")),
1469            Some(&OsString::from(""))
1470        );
1471    }
1472
1473    #[test]
1474    fn child_environment_mediator_pipe_sets_named_pipe_not_http_proxy() {
1475        let scratch = tempfile::tempdir().unwrap();
1476        let pipe = r"\\.\pipe\a3s-sandbox-test";
1477        let environment = compose_child_env(None, scratch.path(), None, Some(pipe), None).unwrap();
1478        assert_eq!(
1479            environment.get(OsStr::new("A3S_SANDBOX_MEDIATOR_PIPE")),
1480            Some(&OsString::from(pipe))
1481        );
1482        assert!(
1483            !environment.contains_key(OsStr::new("HTTP_PROXY"))
1484                && !environment.contains_key(OsStr::new("HTTPS_PROXY"))
1485                && !environment.contains_key(OsStr::new("ALL_PROXY")),
1486            "Windows named-pipe bridge must not invent loopback HTTP_PROXY"
1487        );
1488    }
1489
1490    #[test]
1491    fn protected_path_matching_is_case_insensitive_and_traversal_safe() {
1492        for path in [
1493            ".git/config",
1494            ".GIT/HEAD",
1495            r".a3s\policy.acl",
1496            ".mcp.json",
1497            ".zshrc",
1498        ] {
1499            assert!(is_protected_workspace_path(path), "{path}");
1500        }
1501        for path in [
1502            "src/.git/config",
1503            "../.git/config",
1504            ".gitignore",
1505            "src/main.rs",
1506            ".a3s/loops/goal-1/ACCEPTANCE.md",
1507            r".a3s\loops\goal-1\STATE.md",
1508        ] {
1509            assert!(!is_protected_workspace_path(path), "{path}");
1510        }
1511    }
1512
1513    #[test]
1514    fn policy_discovers_case_variant_control_metadata() {
1515        let workspace = tempfile::tempdir().unwrap();
1516        let scratch = tempfile::tempdir().unwrap();
1517        std::fs::create_dir(workspace.path().join(".GIT")).unwrap();
1518        std::fs::write(workspace.path().join(".MCP.JSON"), "control").unwrap();
1519
1520        let policy = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1521        let workspace = workspace.path().canonicalize().unwrap();
1522        assert!(policy.deny_write.contains(&workspace.join(".GIT")));
1523        assert!(policy.deny_write.contains(&workspace.join(".MCP.JSON")));
1524    }
1525
1526    #[test]
1527    fn git_worktree_pointer_is_resolved_for_case_variant_gitfiles() {
1528        let parent = tempfile::tempdir().unwrap();
1529        let workspace = parent.path().join("workspace");
1530        let git_dir = parent.path().join("git-dir");
1531        std::fs::create_dir(&workspace).unwrap();
1532        std::fs::create_dir(&git_dir).unwrap();
1533        std::fs::write(workspace.join(".GIT"), "gitdir: ../git-dir\n").unwrap();
1534        let scratch = tempfile::tempdir().unwrap();
1535
1536        let policy = EnforcedPolicy::for_execution(&workspace, scratch.path()).unwrap();
1537        assert!(policy.deny_write.contains(&git_dir.canonicalize().unwrap()));
1538    }
1539
1540    #[test]
1541    fn nested_secret_scan_matches_case_variant_environment_files() {
1542        let workspace = tempfile::tempdir().unwrap();
1543        std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1544        std::fs::write(workspace.path().join("src/config/.ENV.local"), "secret").unwrap();
1545
1546        let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1547        assert!(paths.contains(&workspace.path().join("src/config/.ENV.local")));
1548    }
1549
1550    #[test]
1551    fn monorepo_workspace_scan_stays_under_entry_limit() {
1552        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
1553        let workspace = workspace.canonicalize().unwrap();
1554        let scratch = tempfile::tempdir().unwrap();
1555        let start = std::time::Instant::now();
1556        let result = EnforcedPolicy::for_execution(&workspace, scratch.path());
1557        let elapsed = start.elapsed();
1558        match result {
1559            Ok(_) => eprintln!("monorepo_scan_ok elapsed_ms={}", elapsed.as_millis()),
1560            Err(error) => panic!(
1561                "monorepo_scan_failed elapsed_ms={}: {error:#}",
1562                elapsed.as_millis()
1563            ),
1564        }
1565    }
1566
1567    #[test]
1568    fn scan_directory_filter_handles_case_variants() {
1569        for name in [".git", ".GIT", "Node_Modules", "TARGET"] {
1570            assert!(should_skip_workspace_scan_directory(OsStr::new(name)));
1571        }
1572        assert!(!should_skip_workspace_scan_directory(OsStr::new("src")));
1573    }
1574
1575    #[test]
1576    fn nested_environment_files_and_hardlinks_enter_the_deny_set() {
1577        let workspace = tempfile::tempdir().unwrap();
1578        let scratch = tempfile::tempdir().unwrap();
1579        std::fs::create_dir_all(workspace.path().join("nested")).unwrap();
1580        std::fs::write(workspace.path().join("nested/.env.secret"), "secret").unwrap();
1581        let outside = scratch.path().join("outside-secret");
1582        std::fs::write(&outside, "outside").unwrap();
1583        std::fs::hard_link(&outside, workspace.path().join("hardlink-secret")).unwrap();
1584
1585        let policy = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1586        let workspace = workspace.path().canonicalize().unwrap();
1587
1588        assert!(policy
1589            .deny_read
1590            .contains(&workspace.join("nested/.env.secret")));
1591        assert!(policy
1592            .deny_read
1593            .contains(&workspace.join("hardlink-secret")));
1594        assert!(policy
1595            .deny_write
1596            .contains(&workspace.join("hardlink-secret")));
1597    }
1598
1599    #[cfg(any(unix, windows))]
1600    #[test]
1601    fn hardlink_scan_skips_build_and_package_stores() {
1602        let workspace = tempfile::tempdir().unwrap();
1603        let outside = tempfile::tempdir().unwrap();
1604        let source = outside.path().join("source");
1605        std::fs::write(&source, "outside").unwrap();
1606        for directory in ["node_modules", "target", ".a3s"] {
1607            let directory = workspace.path().join(directory);
1608            std::fs::create_dir_all(&directory).unwrap();
1609            std::fs::hard_link(&source, directory.join("linked")).unwrap();
1610        }
1611        std::fs::create_dir_all(workspace.path().join("src")).unwrap();
1612        std::fs::hard_link(&source, workspace.path().join("src/linked")).unwrap();
1613
1614        let hardlinks = workspace_hardlink_paths(workspace.path()).unwrap();
1615        assert_eq!(hardlinks.len(), 1);
1616        assert!(hardlinks[0].ends_with("src/linked"));
1617        assert!(!hardlinks
1618            .iter()
1619            .any(|path| path.ends_with("node_modules/linked")));
1620        assert!(!hardlinks.iter().any(|path| path.ends_with("target/linked")));
1621        assert!(!hardlinks.iter().any(|path| path.ends_with(".a3s/linked")));
1622    }
1623
1624    #[cfg(any(unix, windows))]
1625    #[test]
1626    fn credential_hardlink_aliases_inside_package_stores_enter_the_deny_set() {
1627        let workspace = tempfile::tempdir().unwrap();
1628        let scratch = tempfile::tempdir().unwrap();
1629        let env_path = workspace.path().join(".env");
1630        std::fs::write(&env_path, "SECRET=1").unwrap();
1631        for directory in ["node_modules", "target"] {
1632            let directory = workspace.path().join(directory);
1633            std::fs::create_dir_all(&directory).unwrap();
1634            std::fs::hard_link(&env_path, directory.join("linked-secret")).unwrap();
1635        }
1636        // Non-credential outside hardlinks in package stores stay out of the
1637        // bulk deny set (Seatbelt cannot name every Cargo object hardlink).
1638        let outside = scratch.path().join("ordinary");
1639        std::fs::write(&outside, "ordinary").unwrap();
1640        std::fs::hard_link(&outside, workspace.path().join("node_modules/ordinary")).unwrap();
1641
1642        let policy = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1643        let workspace = workspace.path().canonicalize().unwrap();
1644
1645        assert!(policy
1646            .deny_write
1647            .contains(&workspace.join("node_modules/linked-secret")));
1648        assert!(policy
1649            .deny_write
1650            .contains(&workspace.join("target/linked-secret")));
1651        assert!(!policy
1652            .deny_write
1653            .contains(&workspace.join("node_modules/ordinary")));
1654    }
1655
1656    #[test]
1657    fn nested_secret_scan_skips_control_and_build_stores() {
1658        let workspace = tempfile::tempdir().unwrap();
1659        std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1660        std::fs::create_dir_all(workspace.path().join("node_modules/package")).unwrap();
1661        std::fs::create_dir_all(workspace.path().join("target/debug")).unwrap();
1662        std::fs::create_dir_all(workspace.path().join(".git")).unwrap();
1663        for path in [
1664            "src/config/.env.secret",
1665            "node_modules/package/.env.secret",
1666            "target/debug/.env.secret",
1667            ".git/.env.secret",
1668        ] {
1669            std::fs::write(workspace.path().join(path), "secret").unwrap();
1670        }
1671
1672        let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1673        assert!(paths.contains(&workspace.path().join("src/config/.env.secret")));
1674        assert!(!paths.contains(&workspace.path().join("node_modules/package/.env.secret")));
1675        assert!(!paths.contains(&workspace.path().join("target/debug/.env.secret")));
1676        assert!(!paths.contains(&workspace.path().join(".git/.env.secret")));
1677    }
1678
1679    #[cfg(unix)]
1680    #[test]
1681    fn nested_secret_scan_fails_closed_at_depth_limit() {
1682        let workspace = tempfile::tempdir().unwrap();
1683        let mut current = workspace.path().to_path_buf();
1684        for index in 0..=MAX_WORKSPACE_SCAN_DEPTH {
1685            current.push(format!("level-{index}"));
1686            std::fs::create_dir(&current).unwrap();
1687        }
1688
1689        let error = workspace_sensitive_paths(workspace.path()).unwrap_err();
1690        assert!(error.to_string().contains("depth"), "{error:#}");
1691    }
1692
1693    #[test]
1694    fn executable_resolution_rejects_workspace_tools() {
1695        let workspace = tempfile::tempdir().unwrap();
1696        let candidate = workspace.path().join("untrusted-tool");
1697        std::fs::write(&candidate, "#!/bin/sh\nexit 0\n").unwrap();
1698        #[cfg(unix)]
1699        {
1700            use std::os::unix::fs::PermissionsExt;
1701            std::fs::set_permissions(&candidate, std::fs::Permissions::from_mode(0o755)).unwrap();
1702        }
1703
1704        let error = resolve_executable(&candidate, workspace.path()).unwrap_err();
1705        assert!(error.to_string().contains("inside the active workspace"));
1706    }
1707
1708    #[cfg(any(target_os = "linux", target_os = "macos"))]
1709    #[test]
1710    fn path_ancestors_exclude_the_filesystem_root() {
1711        let ancestors = path_ancestors(Path::new("/a/b/c"));
1712        assert_eq!(ancestors, vec![PathBuf::from("/a"), PathBuf::from("/a/b")]);
1713    }
1714
1715    #[cfg(any(target_os = "linux", windows))]
1716    #[test]
1717    fn only_protected_workspace_roots_require_directory_placeholders() {
1718        let workspace = Path::new("/workspace");
1719        assert!(requires_directory_placeholder(
1720            workspace,
1721            &workspace.join(".a3s")
1722        ));
1723        assert!(requires_directory_placeholder(
1724            workspace,
1725            &workspace.join(".GIT")
1726        ));
1727        assert!(!requires_directory_placeholder(
1728            workspace,
1729            &workspace.join(".gitmodules")
1730        ));
1731        assert!(!requires_directory_placeholder(
1732            workspace,
1733            &workspace.join(".a3s/os-auth.json")
1734        ));
1735        assert!(!requires_directory_placeholder(
1736            workspace,
1737            Path::new("/outside/.a3s")
1738        ));
1739    }
1740
1741    #[cfg(unix)]
1742    #[test]
1743    fn protected_workspace_symlinks_fail_closed() {
1744        use std::os::unix::fs::symlink;
1745
1746        let workspace = tempfile::tempdir().unwrap();
1747        let scratch = tempfile::tempdir().unwrap();
1748        let outside = tempfile::tempdir().unwrap();
1749        symlink(outside.path(), workspace.path().join(".git")).unwrap();
1750
1751        let error = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap_err();
1752        assert!(error.to_string().contains("symbolic link"), "{error:#}");
1753    }
1754}