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