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.66";
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        deduplicate_paths(&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        let _ = scratch;
615        Ok(compose_wrapper_env(workspace))
616    }
617    #[cfg(windows)]
618    {
619        let mut environment = compose_child_env(explicit, scratch)?;
620        remove_bootstrap_injection_variables(&mut environment);
621        if let Some(path) = trusted_wrapper_path(workspace) {
622            environment.insert(OsString::from("PATH"), path);
623        } else {
624            environment.remove(OsStr::new("PATH"));
625        }
626        Ok(environment)
627    }
628}
629
630#[cfg(not(windows))]
631fn compose_wrapper_env(workspace: &Path) -> HashMap<OsString, OsString> {
632    const SAFE_KEYS: &[&str] = &[
633        "HOME",
634        "USER",
635        "LOGNAME",
636        "LANG",
637        "LC_ALL",
638        "LC_CTYPE",
639        "TZ",
640        "SYSTEMROOT",
641        "WINDIR",
642        "COMSPEC",
643        "PATHEXT",
644    ];
645    let mut environment = HashMap::new();
646    for key in SAFE_KEYS {
647        if let Some(value) = std::env::var_os(key) {
648            environment.insert(OsString::from(key), value);
649        }
650    }
651    for (key, value) in std::env::vars_os() {
652        if key.to_string_lossy().starts_with("LC_") {
653            environment.insert(key, value);
654        }
655    }
656    if let Some(path) = trusted_wrapper_path(workspace) {
657        environment.insert(OsString::from("PATH"), path);
658    }
659    remove_bootstrap_injection_variables(&mut environment);
660    environment
661}
662
663fn trusted_wrapper_path(workspace: &Path) -> Option<OsString> {
664    let path = std::env::var_os("PATH")?;
665    let directories = std::env::split_paths(&path)
666        .filter_map(|directory| {
667            let absolute = if directory.is_absolute() {
668                directory
669            } else {
670                std::env::current_dir().ok()?.join(directory)
671            };
672            let canonical = absolute.canonicalize().ok()?;
673            (canonical.is_dir() && !canonical.starts_with(workspace)).then_some(canonical)
674        })
675        .collect::<Vec<_>>();
676    std::env::join_paths(directories).ok()
677}
678
679fn remove_bootstrap_injection_variables(environment: &mut HashMap<OsString, OsString>) {
680    const BLOCKED: &[&str] = &[
681        "BASH_ENV",
682        "ENV",
683        "NODE_OPTIONS",
684        "NODE_PATH",
685        "PYTHONHOME",
686        "PYTHONPATH",
687        "PYTHONSTARTUP",
688        "PYTHONINSPECT",
689        "RUBYOPT",
690        "RUBYLIB",
691        "PERL5OPT",
692        "PERL5LIB",
693        "LUA_INIT",
694        "JAVA_TOOL_OPTIONS",
695        "JDK_JAVA_OPTIONS",
696        "_JAVA_OPTIONS",
697        "LD_PRELOAD",
698        "LD_LIBRARY_PATH",
699        "DYLD_INSERT_LIBRARIES",
700        "DYLD_LIBRARY_PATH",
701    ];
702    environment.retain(|key, _| {
703        let key = key.to_string_lossy();
704        !BLOCKED
705            .iter()
706            .any(|blocked| key.eq_ignore_ascii_case(blocked))
707            && !key.to_ascii_uppercase().starts_with("LUA_INIT_")
708    });
709}
710
711#[cfg(not(windows))]
712fn environment_assignment(key: &OsStr, value: &OsStr) -> OsString {
713    let mut assignment = key.to_os_string();
714    assignment.push("=");
715    assignment.push(value);
716    assignment
717}
718
719pub(crate) fn sensitive_paths() -> Vec<PathBuf> {
720    let mut paths = dirs::home_dir()
721        .map(|home| default_sensitive_paths(&home))
722        .unwrap_or_default();
723
724    extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
725    extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
726    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
727    extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
728    for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
729        extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
730    }
731    for variable in [
732        "A3S_KIMI_DESKTOP_HOME",
733        "KIMI_DESKTOP_HOME",
734        "WORKBUDDY_CONFIG_DIR",
735        "CODEBUDDY_CONFIG_DIR",
736    ] {
737        extend_configured_secret(&mut paths, variable, None);
738    }
739    paths
740}
741
742fn read_denied_roots() -> Vec<PathBuf> {
743    #[cfg(windows)]
744    {
745        // The Windows provider runs commands under its dedicated sandbox user.
746        // Broad parent-directory DENY ACEs would override the narrower
747        // workspace grant, so rely on that account boundary plus the explicit
748        // credential denies above.
749        Vec::new()
750    }
751    #[cfg(not(windows))]
752    {
753        let mut roots = Vec::new();
754        if let Some(home) = dirs::home_dir() {
755            roots.push(home);
756        }
757        let temp = std::env::temp_dir();
758        roots.push(temp.canonicalize().unwrap_or(temp));
759        roots
760    }
761}
762
763fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
764    const TOOLCHAIN_ROOTS: &[&str] = &[
765        "CARGO_HOME",
766        "RUSTUP_HOME",
767        "GOPATH",
768        "GOROOT",
769        "GOMODCACHE",
770        "NVM_DIR",
771        "FNM_DIR",
772        "VOLTA_HOME",
773        "BUN_INSTALL",
774        "DENO_DIR",
775        "PNPM_HOME",
776        "JAVA_HOME",
777        "GRADLE_USER_HOME",
778        "MAVEN_HOME",
779        "SDKROOT",
780        "DEVELOPER_DIR",
781    ];
782
783    let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
784    for variable in TOOLCHAIN_ROOTS {
785        let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
786            continue;
787        };
788        let path = PathBuf::from(path);
789        if path.is_absolute() && path.exists() {
790            paths.push(path.canonicalize().unwrap_or(path));
791        }
792    }
793    if let Some(path) = std::env::var_os("PATH") {
794        paths.extend(std::env::split_paths(&path).filter_map(|path| {
795            if !path.is_absolute() || !path.exists() {
796                return None;
797            }
798            path.canonicalize().ok()
799        }));
800    }
801    paths
802}
803
804fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
805    [
806        ".ssh",
807        ".gnupg",
808        ".aws",
809        ".azure",
810        ".kube",
811        ".docker",
812        ".config/gcloud",
813        ".config/gh",
814        ".netrc",
815        ".npmrc",
816        ".pypirc",
817        ".cargo/credentials",
818        ".cargo/credentials.toml",
819        ".codex/auth.json",
820        ".claude/.credentials.json",
821        ".claude.json",
822        ".git-credentials",
823        ".config/git/credentials",
824        ".workbuddy",
825        "credentials/kimi-code.json",
826        ".kimi-code/credentials/kimi-code.json",
827        ".kimi/credentials/kimi-code.json",
828        ".config/kimi-desktop/daimon-share",
829        "Library/Application Support/kimi-desktop/daimon-share",
830        ".config/opencode/auth.json",
831        ".local/share/opencode/auth.json",
832        ".gemini/oauth_creds.json",
833        ".terraform.d/credentials.tfrc.json",
834        ".local/share/keyrings",
835        ".password-store",
836        ".a3s/os-auth.json",
837        "Library/Keychains",
838    ]
839    .into_iter()
840    .map(|path| home.join(path))
841    .collect()
842}
843
844pub(crate) fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
845    let mut paths = [
846        ".env",
847        ".env.local",
848        ".env.development",
849        ".env.production",
850        ".env.test",
851        ".netrc",
852        ".npmrc",
853        ".pypirc",
854        ".git-credentials",
855        ".a3s/os-auth.json",
856        ".codex/auth.json",
857        ".claude/.credentials.json",
858        ".claude.json",
859    ]
860    .into_iter()
861    .map(|path| workspace.join(path))
862    .collect::<Vec<_>>();
863    paths.extend(workspace_nested_env_paths(workspace)?);
864    Ok(paths)
865}
866
867fn workspace_nested_env_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
868    let mut pending = vec![(workspace.to_path_buf(), 0usize)];
869    let mut scanned = 0usize;
870    let mut paths = Vec::new();
871
872    while let Some((directory, depth)) = pending.pop() {
873        let entries = std::fs::read_dir(&directory)
874            .with_context(|| format!("failed to scan SRT workspace {}", directory.display()))?;
875        for entry in entries {
876            let entry = entry.with_context(|| {
877                format!("failed to enumerate SRT workspace {}", directory.display())
878            })?;
879            scanned = next_workspace_scan_entry(scanned)?;
880            let path = entry.path();
881            let file_type = entry.file_type().with_context(|| {
882                format!("failed to inspect SRT workspace path {}", path.display())
883            })?;
884            if entry
885                .file_name()
886                .to_str()
887                .is_some_and(|name| name.starts_with(".env"))
888            {
889                paths.push(path);
890            } else if file_type.is_dir() {
891                if should_skip_workspace_scan_directory(&entry.file_name()) {
892                    continue;
893                }
894                ensure_workspace_scan_depth(depth, &path)?;
895                pending.push((path, depth + 1));
896            }
897        }
898    }
899
900    Ok(paths)
901}
902
903pub(crate) fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
904    let mut pending = vec![(workspace.to_path_buf(), 0usize)];
905    let mut scanned = 0usize;
906    let mut hardlinks = Vec::new();
907
908    while let Some((directory, depth)) = pending.pop() {
909        let entries = std::fs::read_dir(&directory)
910            .with_context(|| format!("failed to scan SRT workspace {}", directory.display()))?;
911        for entry in entries {
912            let entry = entry.with_context(|| {
913                format!("failed to enumerate SRT workspace {}", directory.display())
914            })?;
915            scanned = next_workspace_scan_entry(scanned)?;
916
917            let path = entry.path();
918            let metadata = std::fs::symlink_metadata(&path).with_context(|| {
919                format!("failed to inspect SRT workspace path {}", path.display())
920            })?;
921            if metadata.file_type().is_symlink() {
922                continue;
923            }
924            if metadata.is_dir() {
925                if should_skip_workspace_scan_directory(&entry.file_name()) {
926                    continue;
927                }
928                ensure_workspace_scan_depth(depth, &path)?;
929                pending.push((path, depth + 1));
930                continue;
931            }
932            if metadata.is_file() && hard_link_count(&path, &metadata) > 1 {
933                hardlinks.push(path);
934                if hardlinks.len() > MAX_WORKSPACE_HARDLINK_DENIES {
935                    bail!(
936                        "SRT workspace contains more than {MAX_WORKSPACE_HARDLINK_DENIES} multi-link files"
937                    );
938                }
939            }
940        }
941    }
942
943    hardlinks.sort();
944    hardlinks.dedup();
945    Ok(hardlinks)
946}
947
948fn next_workspace_scan_entry(scanned: usize) -> Result<usize> {
949    let scanned = scanned
950        .checked_add(1)
951        .context("SRT workspace scan entry count overflowed")?;
952    if scanned > MAX_WORKSPACE_SCAN_ENTRIES {
953        bail!("SRT workspace exceeds the {MAX_WORKSPACE_SCAN_ENTRIES} entry scan limit");
954    }
955    Ok(scanned)
956}
957
958fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
959    if depth >= MAX_WORKSPACE_SCAN_DEPTH {
960        bail!(
961            "SRT workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
962            path.display()
963        );
964    }
965    Ok(())
966}
967
968pub(crate) fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
969    // Control metadata is already write-protected, while dependency and build
970    // trees commonly contain legitimate package-store hardlinks and can be
971    // extremely large. The local boundary governs source-tree work; hostile
972    // dependency/build execution belongs in the stronger Box/Runtime boundary.
973    matches!(name.to_str(), Some(".git" | "node_modules" | "target"))
974}
975
976#[cfg(unix)]
977pub(crate) fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
978    use std::os::unix::fs::MetadataExt;
979    metadata.nlink()
980}
981
982#[cfg(windows)]
983pub(crate) fn hard_link_count(path: &Path, _metadata: &std::fs::Metadata) -> u64 {
984    use std::os::windows::io::AsRawHandle;
985    use windows_sys::Win32::Storage::FileSystem::{
986        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
987    };
988
989    let Ok(file) = std::fs::File::open(path) else {
990        return u64::MAX;
991    };
992    let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
993    // SAFETY: `file` owns a valid handle for the duration of this call and
994    // `information` points to writable storage of the required type.
995    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
996        return u64::MAX;
997    }
998    u64::from(information.nNumberOfLinks.max(1))
999}
1000
1001#[cfg(not(any(unix, windows)))]
1002pub(crate) fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
1003    1
1004}
1005
1006fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
1007    let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
1008        return;
1009    };
1010    let root = PathBuf::from(root);
1011    if !root.is_absolute() {
1012        return;
1013    }
1014    paths.push(match suffix {
1015        Some(suffix) => root.join(suffix),
1016        None => root,
1017    });
1018}
1019
1020fn protected_workspace_paths(workspace: &Path) -> Vec<PathBuf> {
1021    PROTECTED_WORKSPACE_DIRECTORIES
1022        .iter()
1023        .chain(PROTECTED_WORKSPACE_FILES)
1024        .copied()
1025        .map(|path| workspace.join(path))
1026        .collect()
1027}
1028
1029fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
1030    let dot_git = workspace.join(".git");
1031    if dot_git.is_dir() {
1032        return dot_git.canonicalize().ok();
1033    }
1034    let source = std::fs::read_to_string(dot_git).ok()?;
1035    let relative = source.trim().strip_prefix("gitdir:")?.trim();
1036    let path = Path::new(relative);
1037    let path = if path.is_absolute() {
1038        path.to_path_buf()
1039    } else {
1040        workspace.join(path)
1041    };
1042    path.canonicalize().ok()
1043}
1044
1045fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
1046    paths.sort();
1047    paths.dedup();
1048}
1049
1050fn path_strings<'a>(paths: impl IntoIterator<Item = &'a Path>) -> Vec<String> {
1051    paths
1052        .into_iter()
1053        .map(|path| path.to_string_lossy().into_owned())
1054        .collect()
1055}
1056
1057#[cfg(test)]
1058mod tests;