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