Skip to main content

a3s_code_core/sandbox/
srt.rs

1//! Local command sandbox backed by the `srt` process wrapper.
2//!
3//! The adapter deliberately has no unsandboxed fallback. Hosts decide whether
4//! an unavailable sandbox should require approval or fail closed before a tool
5//! reaches this implementation.
6
7use super::{
8    BashSandbox, SandboxCommandRequest, SandboxExecutionOutput, SandboxOutput,
9    PROTECTED_WORKSPACE_DIRECTORIES, PROTECTED_WORKSPACE_FILES,
10};
11use anyhow::{anyhow, bail, Context, Result};
12use async_trait::async_trait;
13use serde_json::json;
14use std::collections::HashMap;
15use std::ffi::{OsStr, OsString};
16use std::path::{Path, PathBuf};
17use std::process::Stdio;
18use tokio::process::Command;
19
20const DEFAULT_TIMEOUT_MS: u64 = 120_000;
21const MAX_WORKSPACE_SCAN_ENTRIES: usize = 1_000_000;
22const MAX_WORKSPACE_SCAN_DEPTH: usize = 64;
23const MAX_WORKSPACE_HARDLINK_DENIES: usize = 4_096;
24/// npm package accepted by the verified SRT constructor.
25pub const SRT_NPM_PACKAGE_NAME: &str = "@anthropic-ai/sandbox-runtime";
26/// Exact SRT version provisioned by the current A3S CLI managed-runtime path.
27///
28/// Core accepts the tested compatibility range below so a host can roll a
29/// compatible patch independently. The CLI deliberately installs one exact
30/// version until an A3S-signed component artifact replaces registry bootstrap.
31pub const MANAGED_SRT_VERSION: &str = "0.0.67";
32const MINIMUM_SRT_VERSION: (u64, u64, u64) = (0, 0, 66);
33const MAXIMUM_SRT_VERSION_EXCLUSIVE: (u64, u64, u64) = (0, 1, 0);
34
35/// An `srt`-backed local command sandbox rooted at one workspace.
36#[derive(Debug)]
37pub struct SrtBashSandbox {
38    binary: PathBuf,
39    node: Option<PathBuf>,
40    shell: PathBuf,
41    #[cfg(not(windows))]
42    env_binary: PathBuf,
43    workspace: PathBuf,
44    workspace_hardlink_paths: Vec<PathBuf>,
45}
46
47impl SrtBashSandbox {
48    /// Build an adapter from an explicit `srt` executable.
49    pub fn new(binary: impl Into<PathBuf>, workspace: impl Into<PathBuf>) -> Result<Self> {
50        let binary = binary.into();
51        if binary.components().count() == 1 {
52            bail!("an explicit SRT executable path is required; PATH discovery is unsupported");
53        }
54        let workspace = workspace
55            .into()
56            .canonicalize()
57            .context("failed to canonicalize the SRT workspace")?;
58        if !workspace.is_dir() {
59            bail!("SRT workspace is not a directory: {}", workspace.display());
60        }
61        let binary = resolve_executable(binary, Some(&workspace))?;
62        if binary.starts_with(&workspace) {
63            bail!(
64                "refusing to trust an SRT executable from inside the active workspace: {}",
65                binary.display()
66            );
67        }
68        let node = node_script(&binary)
69            .then(|| resolve_executable(PathBuf::from("node"), Some(&workspace)))
70            .transpose()
71            .context("failed to resolve a trusted Node.js launcher for SRT")?;
72        Self::from_resolved(binary, node, workspace)
73    }
74
75    /// Build an adapter from a compatible npm installation at an exact path.
76    ///
77    /// Unlike [`Self::new`], this path verifies the package identity and tested
78    /// version before accepting its CLI. It is intended for embedding hosts
79    /// that provision SRT outside `PATH`.
80    pub fn from_verified_npm(
81        binary: impl Into<PathBuf>,
82        workspace: impl Into<PathBuf>,
83    ) -> Result<Self> {
84        let binary = binary.into();
85        let installation = inspect_srt_installation(&binary)?;
86        ensure_supported_srt_version(&installation.version)?;
87        Self::new(installation.cli, workspace)
88    }
89
90    /// Build from a verified npm installation and an explicitly selected Node
91    /// executable.
92    ///
93    /// Managed hosts use this form so command startup cannot select a different
94    /// `node` from a later `PATH` entry after provisioning has completed.
95    pub fn from_verified_npm_with_node(
96        binary: impl Into<PathBuf>,
97        node: impl Into<PathBuf>,
98        workspace: impl Into<PathBuf>,
99    ) -> Result<Self> {
100        let workspace = workspace
101            .into()
102            .canonicalize()
103            .context("failed to canonicalize the SRT workspace")?;
104        if !workspace.is_dir() {
105            bail!("SRT workspace is not a directory: {}", workspace.display());
106        }
107        let installation = inspect_srt_installation(&binary.into())?;
108        ensure_supported_srt_version(&installation.version)?;
109        let binary = resolve_executable(installation.cli, Some(&workspace))?;
110        let node = resolve_executable(node.into(), Some(&workspace))
111            .context("failed to resolve the managed Node.js launcher for SRT")?;
112        Self::from_resolved(binary, Some(node), workspace)
113    }
114
115    pub fn binary(&self) -> &Path {
116        &self.binary
117    }
118
119    pub fn workspace(&self) -> &Path {
120        &self.workspace
121    }
122
123    fn settings(&self, scratch: &Path) -> Result<serde_json::Value> {
124        let mut deny_write = protected_workspace_paths(&self.workspace);
125        if let Some(git_dir) = resolved_git_dir(&self.workspace) {
126            deny_write.push(git_dir);
127        }
128        let mut sensitive_paths = sensitive_paths();
129        sensitive_paths.extend(workspace_sensitive_paths(&self.workspace)?);
130        sensitive_paths.extend(self.workspace_hardlink_paths.iter().cloned());
131        let mut deny_read = sensitive_paths.clone();
132        deny_read.extend(read_denied_roots());
133        let mut allow_read = readable_tool_paths(&self.workspace, scratch);
134        deny_write.extend(sensitive_paths.iter().cloned());
135        remove_redundant_deny_write_descendants(&mut deny_write);
136        deduplicate_paths(&mut sensitive_paths);
137        deduplicate_paths(&mut deny_read);
138        deduplicate_paths(&mut allow_read);
139
140        Ok(json!({
141            "network": {
142                "allowedDomains": [],
143                "deniedDomains": [],
144                "allowUnixSockets": [],
145                "allowAllUnixSockets": false,
146                "allowLocalBinding": false
147            },
148            "filesystem": {
149                "allowWrite": path_strings([self.workspace.as_path(), scratch]),
150                "denyWrite": path_strings(deny_write.iter().map(PathBuf::as_path)),
151                "denyRead": path_strings(deny_read.iter().map(PathBuf::as_path)),
152                // User and temporary roots are hidden, then the workspace,
153                // private scratch directory, and explicit toolchain roots are
154                // re-exposed. The tested SRT range preserves more-specific
155                // literal credential denies inside an allowed workspace.
156                "allowRead": path_strings(allow_read.iter().map(PathBuf::as_path))
157            },
158            "mandatoryDenySearchDepth": 10,
159            "enableWeakerNestedSandbox": false,
160            "enableWeakerNetworkIsolation": false,
161            "allowAppleEvents": false
162        }))
163    }
164
165    fn from_resolved(binary: PathBuf, node: Option<PathBuf>, workspace: PathBuf) -> Result<Self> {
166        #[cfg(not(windows))]
167        let shell = resolve_executable(PathBuf::from("bash"), Some(&workspace))
168            .context("failed to resolve a trusted bash executable for SRT")?;
169        #[cfg(windows)]
170        let shell = resolve_executable(PathBuf::from("powershell.exe"), Some(&workspace))
171            .context("failed to resolve a trusted PowerShell executable for SRT")?;
172        #[cfg(not(windows))]
173        let env_binary = resolve_executable(PathBuf::from("env"), Some(&workspace))
174            .context("failed to resolve a trusted env executable for SRT")?;
175        let workspace_hardlink_paths = workspace_hardlink_paths(&workspace)?;
176        Ok(Self {
177            binary,
178            node,
179            shell,
180            #[cfg(not(windows))]
181            env_binary,
182            workspace,
183            workspace_hardlink_paths,
184        })
185    }
186
187    async fn execute_request(
188        &self,
189        request: SandboxCommandRequest,
190    ) -> Result<SandboxExecutionOutput> {
191        let scratch = tempfile::Builder::new()
192            .prefix("a3s-code-srt-")
193            .tempdir()
194            .context("failed to create SRT scratch directory")?;
195        let settings_path = scratch.path().join("settings.json");
196        let settings = serde_json::to_vec(&self.settings(scratch.path())?)
197            .context("failed to serialize SRT settings")?;
198        tokio::fs::write(&settings_path, settings)
199            .await
200            .context("failed to write SRT settings")?;
201
202        let mut command = if let Some(node) = &self.node {
203            let mut command = Command::new(node);
204            command.arg(&self.binary);
205            command
206        } else {
207            Command::new(&self.binary)
208        };
209        command
210            .arg("--settings")
211            .arg(&settings_path)
212            // Stop the SRT CLI parser before the wrapped executable's flags.
213            // Without this delimiter, flags such as `env -i` or PowerShell's
214            // `-NoProfile` are consumed as SRT options before the sandbox
215            // command is constructed.
216            .arg("--");
217        #[cfg(not(windows))]
218        {
219            command.arg(&self.env_binary).arg("-i");
220            for (key, value) in compose_child_env(request.env.as_deref(), scratch.path())? {
221                command.arg(environment_assignment(&key, &value));
222            }
223            command.arg(&self.shell).arg("-c").arg(&request.command);
224        }
225        #[cfg(windows)]
226        {
227            let wrapped = crate::tools::builtin::bash::build_powershell_command(&request.command);
228            let encoded = crate::tools::builtin::bash::encode_powershell_command(&wrapped);
229            command
230                .arg(&self.shell)
231                .args([
232                    "-NoLogo",
233                    "-NoProfile",
234                    "-NonInteractive",
235                    "-ExecutionPolicy",
236                    "Bypass",
237                    "-EncodedCommand",
238                    &encoded,
239                ])
240                .creation_flags(crate::tools::builtin::bash::CREATE_NO_WINDOW);
241        }
242        command
243            .current_dir(&self.workspace)
244            .env_clear()
245            .envs(compose_srt_process_env(
246                request.env.as_deref(),
247                scratch.path(),
248                &self.workspace,
249            )?)
250            .stdout(Stdio::piped())
251            .stderr(Stdio::piped())
252            .kill_on_drop(true);
253        crate::tools::process::configure_process_group(&mut command);
254
255        let mut child = command
256            .spawn()
257            .with_context(|| format!("failed to start SRT executable {}", self.binary.display()))?;
258        let process = crate::tools::process::read_process_output(
259            &mut child,
260            request.timeout_ms,
261            request.output_observer.as_deref(),
262        )
263        .await
264        .context("failed to wait for SRT command")?;
265        if process.timed_out {
266            return Ok(SandboxExecutionOutput {
267                stdout: process.stdout,
268                stderr: process.stderr,
269                exit_code: -1,
270                timed_out: true,
271            });
272        }
273
274        Ok(SandboxExecutionOutput {
275            stdout: process.stdout,
276            stderr: process.stderr,
277            exit_code: process
278                .status
279                .and_then(|status| status.code())
280                .unwrap_or(-1),
281            timed_out: false,
282        })
283    }
284}
285
286#[async_trait]
287impl BashSandbox for SrtBashSandbox {
288    async fn exec_command(&self, command: &str, guest_workspace: &str) -> Result<SandboxOutput> {
289        let output = self
290            .execute_request(SandboxCommandRequest {
291                command: command.to_string(),
292                guest_workspace: guest_workspace.to_string(),
293                timeout_ms: DEFAULT_TIMEOUT_MS,
294                output_observer: None,
295                env: None,
296            })
297            .await?;
298        Ok(SandboxOutput {
299            stdout: output.stdout,
300            stderr: output.stderr,
301            exit_code: output.exit_code,
302        })
303    }
304
305    async fn exec(&self, request: SandboxCommandRequest) -> Result<SandboxExecutionOutput> {
306        self.execute_request(request).await
307    }
308
309    async fn shutdown(&self) {}
310}
311
312#[derive(Debug)]
313struct SrtInstallation {
314    cli: PathBuf,
315    version: String,
316}
317
318fn inspect_srt_installation(binary: &Path) -> Result<SrtInstallation> {
319    let canonical = binary
320        .canonicalize()
321        .with_context(|| format!("failed to resolve SRT executable {}", binary.display()))?;
322    let mut roots = canonical
323        .ancestors()
324        .take(5)
325        .map(Path::to_path_buf)
326        .collect::<Vec<_>>();
327    if binary
328        .parent()
329        .and_then(Path::file_name)
330        .is_some_and(|name| name.eq_ignore_ascii_case(".bin"))
331    {
332        if let Some(node_modules) = binary.parent().and_then(Path::parent) {
333            roots.push(node_modules.join("@anthropic-ai").join("sandbox-runtime"));
334        }
335    }
336    deduplicate_paths(&mut roots);
337
338    for root in roots {
339        let manifest_path = root.join("package.json");
340        let Ok(source) = std::fs::read(&manifest_path) else {
341            continue;
342        };
343        let manifest: serde_json::Value = serde_json::from_slice(&source)
344            .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
345        if manifest.get("name").and_then(serde_json::Value::as_str) != Some(SRT_NPM_PACKAGE_NAME) {
346            continue;
347        }
348        let version = manifest
349            .get("version")
350            .and_then(serde_json::Value::as_str)
351            .filter(|version| !version.trim().is_empty())
352            .ok_or_else(|| anyhow!("SRT package manifest has no version"))?
353            .to_string();
354        let cli = root
355            .join("dist")
356            .join("cli.js")
357            .canonicalize()
358            .context("failed to resolve the SRT package CLI")?;
359        if !cli.is_file() {
360            bail!("SRT package CLI is not a file: {}", cli.display());
361        }
362        return Ok(SrtInstallation { cli, version });
363    }
364
365    bail!(
366        "refusing unverified `srt` from {}: expected package {}",
367        binary.display(),
368        SRT_NPM_PACKAGE_NAME
369    )
370}
371
372fn ensure_supported_srt_version(version: &str) -> Result<()> {
373    let parsed = parse_semver_triplet(version)
374        .ok_or_else(|| anyhow!("unsupported SRT version format: {version}"))?;
375    if parsed < MINIMUM_SRT_VERSION || parsed >= MAXIMUM_SRT_VERSION_EXCLUSIVE {
376        bail!(
377            "unsupported SRT version {version}; expected >= {}.{}.{} and < {}.{}.{}",
378            MINIMUM_SRT_VERSION.0,
379            MINIMUM_SRT_VERSION.1,
380            MINIMUM_SRT_VERSION.2,
381            MAXIMUM_SRT_VERSION_EXCLUSIVE.0,
382            MAXIMUM_SRT_VERSION_EXCLUSIVE.1,
383            MAXIMUM_SRT_VERSION_EXCLUSIVE.2,
384        );
385    }
386    Ok(())
387}
388
389fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
390    let core = version
391        .trim()
392        .strip_prefix('v')
393        .unwrap_or(version.trim())
394        .split(['-', '+'])
395        .next()?;
396    let mut components = core.split('.');
397    let parsed = (
398        components.next()?.parse().ok()?,
399        components.next()?.parse().ok()?,
400        components.next()?.parse().ok()?,
401    );
402    components.next().is_none().then_some(parsed)
403}
404
405fn node_script(path: &Path) -> bool {
406    if path
407        .extension()
408        .is_some_and(|extension| extension.eq_ignore_ascii_case("js"))
409    {
410        return true;
411    }
412    std::fs::read(path)
413        .ok()
414        .and_then(|source| source.get(..source.len().min(128)).map(Vec::from))
415        .and_then(|prefix| String::from_utf8(prefix).ok())
416        .is_some_and(|prefix| {
417            prefix
418                .lines()
419                .next()
420                .is_some_and(|line| line.starts_with("#!") && line.contains("node"))
421        })
422}
423
424fn resolve_executable(binary: PathBuf, excluded_root: Option<&Path>) -> Result<PathBuf> {
425    let candidate = if binary.components().count() == 1 {
426        find_executable_on_path(&binary, excluded_root).ok_or_else(|| {
427            anyhow!(
428                "required executable was not found on PATH: {}",
429                binary.display()
430            )
431        })?
432    } else {
433        binary
434    };
435    let candidate = candidate
436        .canonicalize()
437        .with_context(|| format!("failed to resolve executable {}", candidate.display()))?;
438    if !candidate.is_file() {
439        bail!("executable is not a file: {}", candidate.display());
440    }
441    if !is_executable(&candidate) {
442        bail!("executable is not executable: {}", candidate.display());
443    }
444    if excluded_root.is_some_and(|root| candidate.starts_with(root)) {
445        bail!(
446            "refusing executable from inside the active workspace: {}",
447            candidate.display()
448        );
449    }
450    Ok(candidate)
451}
452
453fn find_executable_on_path(
454    binary: impl AsRef<OsStr>,
455    excluded_root: Option<&Path>,
456) -> Option<PathBuf> {
457    let binary = binary.as_ref();
458    let path = std::env::var_os("PATH")?;
459    for directory in std::env::split_paths(&path) {
460        let candidate = directory.join(binary);
461        if executable_is_trusted(&candidate, excluded_root) {
462            return Some(candidate);
463        }
464        #[cfg(windows)]
465        {
466            for extension in executable_extensions() {
467                let candidate = directory.join(format!(
468                    "{}{}",
469                    binary.to_string_lossy(),
470                    extension.to_string_lossy()
471                ));
472                if executable_is_trusted(&candidate, excluded_root) {
473                    return Some(candidate);
474                }
475            }
476        }
477    }
478    None
479}
480
481fn executable_is_trusted(candidate: &Path, excluded_root: Option<&Path>) -> bool {
482    if !candidate.is_file() || !is_executable(candidate) {
483        return false;
484    }
485    let Ok(canonical) = candidate.canonicalize() else {
486        return false;
487    };
488    !excluded_root.is_some_and(|root| canonical.starts_with(root))
489}
490
491#[cfg(windows)]
492fn executable_extensions() -> Vec<OsString> {
493    std::env::var_os("PATHEXT")
494        .map(|value| {
495            value
496                .to_string_lossy()
497                .split(';')
498                .filter(|value| !value.is_empty())
499                .map(OsString::from)
500                .collect()
501        })
502        .unwrap_or_else(|| {
503            [".COM", ".EXE", ".BAT", ".CMD"]
504                .into_iter()
505                .map(OsString::from)
506                .collect()
507        })
508}
509
510fn is_executable(path: &Path) -> bool {
511    #[cfg(unix)]
512    {
513        use std::os::unix::fs::PermissionsExt;
514        path.metadata()
515            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
516            .unwrap_or(false)
517    }
518    #[cfg(not(unix))]
519    {
520        path.is_file()
521    }
522}
523
524fn compose_child_env(
525    explicit: Option<&HashMap<String, String>>,
526    scratch: &Path,
527) -> Result<HashMap<OsString, OsString>> {
528    const SAFE_KEYS: &[&str] = &[
529        "PATH",
530        "USER",
531        "LOGNAME",
532        "SHELL",
533        "LANG",
534        "LC_ALL",
535        "LC_CTYPE",
536        "TZ",
537        "TERM",
538        "COLORTERM",
539        "NO_COLOR",
540        "CI",
541        "CARGO_HOME",
542        "RUSTUP_HOME",
543        "RUSTC_WRAPPER",
544        "GOPATH",
545        "GOROOT",
546        "GOMODCACHE",
547        "NVM_DIR",
548        "FNM_DIR",
549        "VOLTA_HOME",
550        "BUN_INSTALL",
551        "DENO_DIR",
552        "PNPM_HOME",
553        "JAVA_HOME",
554        "GRADLE_USER_HOME",
555        "MAVEN_HOME",
556        "SDKROOT",
557        "DEVELOPER_DIR",
558        "PKG_CONFIG_PATH",
559        "LIBRARY_PATH",
560        "CPATH",
561        "CC",
562        "CXX",
563        "AR",
564        "SYSTEMROOT",
565        "WINDIR",
566        "COMSPEC",
567        "PATHEXT",
568    ];
569
570    let mut environment = HashMap::new();
571    for key in SAFE_KEYS {
572        if let Some(value) = std::env::var_os(key) {
573            environment.insert(OsString::from(key), value);
574        }
575    }
576    for (key, value) in std::env::vars_os() {
577        if key.to_string_lossy().starts_with("LC_") {
578            environment.insert(key, value);
579        }
580    }
581    if let Some(explicit) = explicit {
582        for (key, value) in explicit {
583            if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') {
584                bail!("invalid explicit command environment entry: {key:?}");
585            }
586            environment.insert(OsString::from(key), OsString::from(value));
587        }
588    }
589    // Explicit command variables may add ordinary data, but they must not
590    // re-enable shell, language-runtime, or dynamic-loader bootstrap hooks.
591    // Apply this after explicit values so callers cannot override the denylist.
592    remove_bootstrap_injection_variables(&mut environment);
593
594    let scratch = scratch.as_os_str().to_os_string();
595    environment.insert(OsString::from("HOME"), scratch.clone());
596    environment.insert(OsString::from("TMPDIR"), scratch.clone());
597    environment.insert(OsString::from("TMP"), scratch.clone());
598    environment.insert(OsString::from("TEMP"), scratch.clone());
599    environment.insert(OsString::from("XDG_CACHE_HOME"), scratch.clone());
600    environment.insert(OsString::from("XDG_CONFIG_HOME"), scratch.clone());
601    environment.insert(OsString::from("XDG_DATA_HOME"), scratch.clone());
602    environment.insert(OsString::from("XDG_STATE_HOME"), scratch);
603    Ok(environment)
604}
605
606fn compose_srt_process_env(
607    explicit: Option<&HashMap<String, String>>,
608    scratch: &Path,
609    workspace: &Path,
610) -> Result<HashMap<OsString, OsString>> {
611    #[cfg(not(windows))]
612    {
613        let _ = explicit;
614        Ok(compose_wrapper_env(workspace, scratch))
615    }
616    #[cfg(windows)]
617    {
618        let mut environment = compose_child_env(explicit, scratch)?;
619        remove_bootstrap_injection_variables(&mut environment);
620        if let Some(path) = trusted_wrapper_path(workspace) {
621            environment.insert(OsString::from("PATH"), path);
622        } else {
623            environment.remove(OsStr::new("PATH"));
624        }
625        Ok(environment)
626    }
627}
628
629#[cfg(not(windows))]
630fn compose_wrapper_env(workspace: &Path, scratch: &Path) -> HashMap<OsString, OsString> {
631    const SAFE_KEYS: &[&str] = &[
632        "HOME",
633        "USER",
634        "LOGNAME",
635        "LANG",
636        "LC_ALL",
637        "LC_CTYPE",
638        "TZ",
639        "SYSTEMROOT",
640        "WINDIR",
641        "COMSPEC",
642        "PATHEXT",
643    ];
644    let mut environment = HashMap::new();
645    for key in SAFE_KEYS {
646        if let Some(value) = std::env::var_os(key) {
647            environment.insert(OsString::from(key), value);
648        }
649    }
650    for (key, value) in std::env::vars_os() {
651        if key.to_string_lossy().starts_with("LC_") {
652            environment.insert(key, value);
653        }
654    }
655    if let Some(path) = trusted_wrapper_path(workspace) {
656        environment.insert(OsString::from("PATH"), path);
657    }
658    let scratch = scratch.as_os_str().to_os_string();
659    environment.insert(OsString::from("TMPDIR"), scratch.clone());
660    environment.insert(OsString::from("TMP"), scratch.clone());
661    environment.insert(OsString::from("TEMP"), scratch);
662    remove_bootstrap_injection_variables(&mut environment);
663    environment
664}
665
666fn trusted_wrapper_path(workspace: &Path) -> Option<OsString> {
667    let path = std::env::var_os("PATH")?;
668    let directories = std::env::split_paths(&path)
669        .filter_map(|directory| {
670            let absolute = if directory.is_absolute() {
671                directory
672            } else {
673                std::env::current_dir().ok()?.join(directory)
674            };
675            let canonical = absolute.canonicalize().ok()?;
676            (canonical.is_dir() && !canonical.starts_with(workspace)).then_some(canonical)
677        })
678        .collect::<Vec<_>>();
679    std::env::join_paths(directories).ok()
680}
681
682fn remove_bootstrap_injection_variables(environment: &mut HashMap<OsString, OsString>) {
683    const BLOCKED: &[&str] = &[
684        "BASH_ENV",
685        "ENV",
686        "NODE_OPTIONS",
687        "NODE_PATH",
688        "PYTHONHOME",
689        "PYTHONPATH",
690        "PYTHONSTARTUP",
691        "PYTHONINSPECT",
692        "RUBYOPT",
693        "RUBYLIB",
694        "PERL5OPT",
695        "PERL5LIB",
696        "LUA_INIT",
697        "JAVA_TOOL_OPTIONS",
698        "JDK_JAVA_OPTIONS",
699        "_JAVA_OPTIONS",
700        "LD_PRELOAD",
701        "LD_LIBRARY_PATH",
702        "DYLD_INSERT_LIBRARIES",
703        "DYLD_LIBRARY_PATH",
704    ];
705    environment.retain(|key, _| {
706        let key = key.to_string_lossy();
707        !BLOCKED
708            .iter()
709            .any(|blocked| key.eq_ignore_ascii_case(blocked))
710            && !key.to_ascii_uppercase().starts_with("LUA_INIT_")
711    });
712}
713
714#[cfg(not(windows))]
715fn environment_assignment(key: &OsStr, value: &OsStr) -> OsString {
716    let mut assignment = key.to_os_string();
717    assignment.push("=");
718    assignment.push(value);
719    assignment
720}
721
722pub(crate) fn sensitive_paths() -> Vec<PathBuf> {
723    let mut paths = dirs::home_dir()
724        .map(|home| default_sensitive_paths(&home))
725        .unwrap_or_default();
726
727    extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
728    extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
729    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
730    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
731    for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
732        extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
733    }
734    for variable in [
735        "A3S_KIMI_DESKTOP_HOME",
736        "KIMI_DESKTOP_HOME",
737        "WORKBUDDY_CONFIG_DIR",
738        "CODEBUDDY_CONFIG_DIR",
739    ] {
740        extend_configured_secret(&mut paths, variable, None);
741    }
742    paths
743}
744
745fn read_denied_roots() -> Vec<PathBuf> {
746    #[cfg(windows)]
747    {
748        // The Windows provider runs commands under its dedicated sandbox user.
749        // Broad parent-directory DENY ACEs would override the narrower
750        // workspace grant, so rely on that account boundary plus the explicit
751        // credential denies above.
752        Vec::new()
753    }
754    #[cfg(not(windows))]
755    {
756        let mut roots = Vec::new();
757        if let Some(home) = dirs::home_dir() {
758            roots.push(home);
759        }
760        let temp = std::env::temp_dir();
761        roots.push(temp.canonicalize().unwrap_or(temp));
762        roots
763    }
764}
765
766fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
767    const TOOLCHAIN_ROOTS: &[&str] = &[
768        "CARGO_HOME",
769        "RUSTUP_HOME",
770        "GOPATH",
771        "GOROOT",
772        "GOMODCACHE",
773        "NVM_DIR",
774        "FNM_DIR",
775        "VOLTA_HOME",
776        "BUN_INSTALL",
777        "DENO_DIR",
778        "PNPM_HOME",
779        "JAVA_HOME",
780        "GRADLE_USER_HOME",
781        "MAVEN_HOME",
782        "SDKROOT",
783        "DEVELOPER_DIR",
784    ];
785
786    let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
787    for variable in TOOLCHAIN_ROOTS {
788        let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
789            continue;
790        };
791        let path = PathBuf::from(path);
792        if path.is_absolute() && path.exists() {
793            paths.push(path.canonicalize().unwrap_or(path));
794        }
795    }
796    if let Some(path) = std::env::var_os("PATH") {
797        paths.extend(std::env::split_paths(&path).filter_map(|path| {
798            if !path.is_absolute() || !path.exists() {
799                return None;
800            }
801            path.canonicalize().ok()
802        }));
803    }
804    paths
805}
806
807fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
808    [
809        ".ssh",
810        ".gnupg",
811        ".aws",
812        ".azure",
813        ".kube",
814        ".docker",
815        ".config/gcloud",
816        ".config/gh",
817        ".netrc",
818        ".npmrc",
819        ".pypirc",
820        ".cargo/credentials",
821        ".cargo/credentials.toml",
822        ".codex/auth.json",
823        ".claude/.credentials.json",
824        ".claude.json",
825        ".git-credentials",
826        ".config/git/credentials",
827        ".workbuddy",
828        "credentials/kimi-code.json",
829        ".kimi-code/credentials/kimi-code.json",
830        ".kimi/credentials/kimi-code.json",
831        ".config/kimi-desktop/daimon-share",
832        "Library/Application Support/kimi-desktop/daimon-share",
833        ".config/opencode/auth.json",
834        ".local/share/opencode/auth.json",
835        ".gemini/oauth_creds.json",
836        ".terraform.d/credentials.tfrc.json",
837        ".local/share/keyrings",
838        ".password-store",
839        ".a3s/os-auth.json",
840        "Library/Keychains",
841    ]
842    .into_iter()
843    .map(|path| home.join(path))
844    .collect()
845}
846
847pub(crate) fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
848    let mut paths = [
849        ".env",
850        ".env.local",
851        ".env.development",
852        ".env.production",
853        ".env.test",
854        ".netrc",
855        ".npmrc",
856        ".pypirc",
857        ".git-credentials",
858        ".a3s/os-auth.json",
859        ".codex/auth.json",
860        ".claude/.credentials.json",
861        ".claude.json",
862    ]
863    .into_iter()
864    .map(|path| workspace.join(path))
865    .collect::<Vec<_>>();
866    paths.extend(workspace_nested_env_paths(workspace)?);
867    Ok(paths)
868}
869
870fn workspace_nested_env_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
871    let mut pending = vec![(workspace.to_path_buf(), 0usize)];
872    let mut scanned = 0usize;
873    let mut paths = Vec::new();
874
875    while let Some((directory, depth)) = pending.pop() {
876        let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
877            format!("failed to scan SRT workspace {}", directory.display())
878        })?
879        else {
880            continue;
881        };
882        for entry in entries {
883            let Some(entry) = workspace_scan_result(entry, || {
884                format!("failed to enumerate SRT workspace {}", directory.display())
885            })?
886            else {
887                continue;
888            };
889            scanned = next_workspace_scan_entry(scanned)?;
890            let path = entry.path();
891            let Some(file_type) = workspace_scan_result(entry.file_type(), || {
892                format!("failed to inspect SRT workspace path {}", path.display())
893            })?
894            else {
895                continue;
896            };
897            if entry
898                .file_name()
899                .to_str()
900                .is_some_and(|name| name.starts_with(".env"))
901            {
902                paths.push(path);
903            } else if file_type.is_dir() {
904                if should_skip_workspace_scan_directory(&entry.file_name()) {
905                    continue;
906                }
907                ensure_workspace_scan_depth(depth, &path)?;
908                pending.push((path, depth + 1));
909            }
910        }
911    }
912
913    Ok(paths)
914}
915
916pub(crate) fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
917    let mut pending = vec![(workspace.to_path_buf(), 0usize)];
918    let mut scanned = 0usize;
919    let mut hardlinks = Vec::new();
920
921    while let Some((directory, depth)) = pending.pop() {
922        let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
923            format!("failed to scan SRT workspace {}", directory.display())
924        })?
925        else {
926            continue;
927        };
928        for entry in entries {
929            let Some(entry) = workspace_scan_result(entry, || {
930                format!("failed to enumerate SRT workspace {}", directory.display())
931            })?
932            else {
933                continue;
934            };
935            scanned = next_workspace_scan_entry(scanned)?;
936
937            let path = entry.path();
938            let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
939                format!("failed to inspect SRT workspace path {}", path.display())
940            })?
941            else {
942                continue;
943            };
944            if metadata.file_type().is_symlink() {
945                continue;
946            }
947            if metadata.is_dir() {
948                if should_skip_workspace_scan_directory(&entry.file_name()) {
949                    continue;
950                }
951                ensure_workspace_scan_depth(depth, &path)?;
952                pending.push((path, depth + 1));
953                continue;
954            }
955            if metadata.is_file() && hard_link_count(&path, &metadata) > 1 {
956                hardlinks.push(path);
957                if hardlinks.len() > MAX_WORKSPACE_HARDLINK_DENIES {
958                    bail!(
959                        "SRT workspace contains more than {MAX_WORKSPACE_HARDLINK_DENIES} multi-link files"
960                    );
961                }
962            }
963        }
964    }
965
966    hardlinks.sort();
967    hardlinks.dedup();
968    Ok(hardlinks)
969}
970
971fn workspace_scan_result<T>(
972    result: std::io::Result<T>,
973    context: impl FnOnce() -> String,
974) -> Result<Option<T>> {
975    match result {
976        Ok(value) => Ok(Some(value)),
977        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
978        Err(error) => Err(error).with_context(context),
979    }
980}
981
982fn next_workspace_scan_entry(scanned: usize) -> Result<usize> {
983    let scanned = scanned
984        .checked_add(1)
985        .context("SRT workspace scan entry count overflowed")?;
986    if scanned > MAX_WORKSPACE_SCAN_ENTRIES {
987        bail!("SRT workspace exceeds the {MAX_WORKSPACE_SCAN_ENTRIES} entry scan limit");
988    }
989    Ok(scanned)
990}
991
992fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
993    if depth >= MAX_WORKSPACE_SCAN_DEPTH {
994        bail!(
995            "SRT workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
996            path.display()
997        );
998    }
999    Ok(())
1000}
1001
1002pub(crate) fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
1003    // Control metadata is already write-protected, while dependency and build
1004    // trees commonly contain legitimate package-store hardlinks and can be
1005    // extremely large. The local boundary governs source-tree work; hostile
1006    // dependency/build execution belongs in the stronger Box/Runtime boundary.
1007    matches!(name.to_str(), Some(".git" | "node_modules" | "target"))
1008}
1009
1010#[cfg(unix)]
1011pub(crate) fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
1012    use std::os::unix::fs::MetadataExt;
1013    metadata.nlink()
1014}
1015
1016#[cfg(windows)]
1017pub(crate) fn hard_link_count(path: &Path, _metadata: &std::fs::Metadata) -> u64 {
1018    use std::os::windows::io::AsRawHandle;
1019    use windows_sys::Win32::Storage::FileSystem::{
1020        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1021    };
1022
1023    let Ok(file) = std::fs::File::open(path) else {
1024        return u64::MAX;
1025    };
1026    let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
1027    // SAFETY: `file` owns a valid handle for the duration of this call and
1028    // `information` points to writable storage of the required type.
1029    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
1030        return u64::MAX;
1031    }
1032    u64::from(information.nNumberOfLinks.max(1))
1033}
1034
1035#[cfg(not(any(unix, windows)))]
1036pub(crate) fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
1037    1
1038}
1039
1040fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
1041    let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
1042        return;
1043    };
1044    let root = PathBuf::from(root);
1045    if !root.is_absolute() {
1046        return;
1047    }
1048    paths.push(match suffix {
1049        Some(suffix) => root.join(suffix),
1050        None => root,
1051    });
1052}
1053
1054fn protected_workspace_paths(workspace: &Path) -> Vec<PathBuf> {
1055    PROTECTED_WORKSPACE_DIRECTORIES
1056        .iter()
1057        .chain(PROTECTED_WORKSPACE_FILES)
1058        .copied()
1059        .map(|path| workspace.join(path))
1060        .collect()
1061}
1062
1063fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
1064    let dot_git = workspace.join(".git");
1065    if dot_git.is_dir() {
1066        return dot_git.canonicalize().ok();
1067    }
1068    let source = std::fs::read_to_string(dot_git).ok()?;
1069    let relative = source.trim().strip_prefix("gitdir:")?.trim();
1070    let path = Path::new(relative);
1071    let path = if path.is_absolute() {
1072        path.to_path_buf()
1073    } else {
1074        workspace.join(path)
1075    };
1076    path.canonicalize().ok()
1077}
1078
1079fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
1080    paths.sort();
1081    paths.dedup();
1082}
1083
1084fn remove_redundant_deny_write_descendants(paths: &mut Vec<PathBuf>) {
1085    deduplicate_paths(paths);
1086    let candidates = paths.clone();
1087    paths.retain(|path| {
1088        !candidates
1089            .iter()
1090            .any(|ancestor| ancestor != path && path.starts_with(ancestor))
1091    });
1092}
1093
1094fn path_strings<'a>(paths: impl IntoIterator<Item = &'a Path>) -> Vec<String> {
1095    paths
1096        .into_iter()
1097        .map(|path| path.to_string_lossy().into_owned())
1098        .collect()
1099}
1100
1101#[cfg(test)]
1102mod tests;