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