1use 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;
24pub const SRT_NPM_PACKAGE_NAME: &str = "@anthropic-ai/sandbox-runtime";
26pub 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#[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 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 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 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 "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(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 .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 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 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 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 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 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;