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.66";
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 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 "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 .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 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 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 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 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;