1use anyhow::{bail, Context, Result};
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::ffi::{OsStr, OsString};
6use std::path::{Path, PathBuf};
7
8const MAX_WORKSPACE_SCAN_ENTRIES: usize = 1_000_000;
9const MAX_WORKSPACE_SCAN_DEPTH: usize = 64;
10const MAX_CREDENTIAL_ALIAS_SCAN_ENTRIES: usize = 5_000_000;
14
15pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
18 ".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
19];
20
21pub const PROTECTED_WORKSPACE_FILES: &[&str] = &[
24 ".gitmodules",
25 ".mcp.json",
26 ".ripgreprc",
27 ".bashrc",
28 ".bash_profile",
29 ".zshrc",
30 ".zprofile",
31 ".profile",
32];
33
34pub fn is_protected_workspace_path(path: &str) -> bool {
41 let normalized = path.replace('\\', "/");
42 let mut components = normalized
43 .split('/')
44 .filter(|component| !component.is_empty() && *component != ".");
45 let Some(first) = components.next() else {
46 return false;
47 };
48 if first == ".." || components.clone().any(|component| component == "..") {
49 return false;
50 }
51
52 if first.eq_ignore_ascii_case(".a3s") {
53 return !components
54 .next()
55 .is_some_and(|second| second.eq_ignore_ascii_case("loops"));
56 }
57
58 PROTECTED_WORKSPACE_DIRECTORIES
59 .iter()
60 .any(|protected| first.eq_ignore_ascii_case(protected))
61 || PROTECTED_WORKSPACE_FILES
62 .iter()
63 .any(|protected| first.eq_ignore_ascii_case(protected))
64}
65
66#[derive(Debug)]
67pub(crate) struct EnforcedPolicy {
68 pub(crate) workspace: PathBuf,
69 pub(crate) scratch: PathBuf,
70 pub(crate) allow_read: Vec<PathBuf>,
71 pub(crate) deny_read: Vec<PathBuf>,
72 pub(crate) allow_write: Vec<PathBuf>,
73 pub(crate) deny_write: Vec<PathBuf>,
74 pub(crate) write_exceptions: Vec<PathBuf>,
77 pub(crate) resources: crate::policy::ResourceLimits,
78 pub(crate) session_write: crate::policy::SessionWriteMode,
79 pub(crate) mediator_port: Option<u16>,
81 pub(crate) mediator_unix_path: Option<PathBuf>,
86 pub(crate) mediator_pipe_name: Option<String>,
90 pub(crate) socks_mediator_port: Option<u16>,
93 pub(crate) socks_mediator_unix_path: Option<PathBuf>,
97 pub(crate) allow_unix_sockets: Vec<PathBuf>,
99 pub(crate) mount_roots: Vec<PathBuf>,
104}
105
106#[derive(Debug, Clone, Copy)]
107enum OverlayKind {
108 Allow,
109 Deny,
110 Exception,
111}
112
113impl EnforcedPolicy {
114 pub(crate) fn compile(
119 document: &crate::policy::SandboxPolicy,
120 workspace: &Path,
121 scratch: &Path,
122 capabilities: crate::policy::BackendCapabilities,
123 ) -> Result<Self> {
124 document
125 .validate_for_backend(capabilities)
126 .context("sandbox policy is not enforceable on this backend")?;
127 let mut enforced = Self::materialize_a3s_bash_baseline(workspace, scratch)?;
128 enforced.resources = document.resources.clone();
129 enforced.session_write = document.filesystem.session_write;
130 enforced.apply_document_overlays(document)?;
131 Ok(enforced)
132 }
133
134 #[cfg(test)]
136 pub(crate) fn for_execution(workspace: &Path, scratch: &Path) -> Result<Self> {
137 Self::compile(
138 &crate::policy::SandboxPolicy::a3s_bash_baseline(),
139 workspace,
140 scratch,
141 crate::policy::BackendCapabilities::native_gate1(),
142 )
143 }
144
145 fn materialize_a3s_bash_baseline(workspace: &Path, scratch: &Path) -> Result<Self> {
146 let workspace = workspace
147 .canonicalize()
148 .context("failed to resolve the native sandbox workspace")?;
149 let scratch = scratch
150 .canonicalize()
151 .context("failed to resolve the native sandbox scratch directory")?;
152
153 let mut protected = protected_workspace_paths(&workspace)?;
154 if let Some(git_dir) = resolved_git_dir(&workspace) {
155 protected.push(git_dir);
156 }
157 expand_existing_canonical_paths(&mut protected);
158
159 let mut sensitive = sensitive_paths();
160 let scan = scan_workspace_security(&workspace)?;
161 sensitive.extend(fixed_workspace_secret_paths(&workspace));
162 sensitive.extend(scan.nested_env);
163 sensitive.extend(scan.source_hardlinks);
164 sensitive.extend(workspace_credential_hardlink_aliases(
165 &workspace, &sensitive,
166 )?);
167 expand_existing_canonical_paths(&mut sensitive);
168
169 let mut deny_read = sensitive.clone();
170 deny_read.extend(read_denied_roots());
171 let mut allow_read = readable_tool_paths(&workspace, &scratch);
172 let allow_write = vec![workspace.clone(), scratch.clone()];
173 let mut deny_write = protected;
174 deny_write.extend(sensitive);
175 validate_denied_workspace_entries(&workspace, &deny_write)?;
176
177 let loops = workspace.join(".a3s").join("loops");
180 std::fs::create_dir_all(&loops).with_context(|| {
181 format!(
182 "failed to create goal-loop write carve-out {}",
183 loops.display()
184 )
185 })?;
186 let mut write_exceptions = vec![loops];
187 expand_existing_canonical_paths(&mut write_exceptions);
188
189 deduplicate_paths(&mut allow_read);
190 deduplicate_paths(&mut deny_read);
191 remove_redundant_descendants(&mut deny_write);
192 deduplicate_paths(&mut write_exceptions);
193
194 Ok(Self {
195 workspace,
196 scratch,
197 allow_read,
198 deny_read,
199 allow_write,
200 deny_write,
201 write_exceptions,
202 resources: crate::policy::ResourceLimits::default(),
203 session_write: crate::policy::SessionWriteMode::Persistent,
204 mediator_port: None,
205 mediator_unix_path: None,
206 mediator_pipe_name: None,
207 socks_mediator_port: None,
208 socks_mediator_unix_path: None,
209 allow_unix_sockets: Vec::new(),
210 mount_roots: Vec::new(),
211 })
212 }
213
214 fn apply_document_overlays(&mut self, document: &crate::policy::SandboxPolicy) -> Result<()> {
215 for rule in &document.filesystem.deny_read {
216 self.deny_read
217 .push(self.resolve_overlay_path(rule, OverlayKind::Deny)?);
218 }
219 for rule in &document.filesystem.deny_write {
220 self.deny_write
221 .push(self.resolve_overlay_path(rule, OverlayKind::Deny)?);
222 }
223 for rule in &document.filesystem.write_exceptions {
224 self.write_exceptions
225 .push(self.resolve_overlay_path(rule, OverlayKind::Exception)?);
226 }
227 for rule in &document.filesystem.allow_read {
228 let path = self.resolve_overlay_path(rule, OverlayKind::Allow)?;
229 self.ensure_within_boundary(&path, "allow_read")?;
230 self.allow_read.push(path);
231 }
232 for rule in &document.filesystem.allow_write {
233 let path = self.resolve_overlay_path(rule, OverlayKind::Allow)?;
234 self.ensure_within_boundary(&path, "allow_write")?;
235 self.allow_write.push(path);
236 }
237 for mount in &document.filesystem.mounts {
238 self.apply_mount(mount)?;
239 }
240 for rule in &document.sockets.allow_unix {
241 let path = self.resolve_overlay_path(rule, OverlayKind::Allow)?;
242 self.allow_unix_sockets.push(path);
243 }
244
245 deduplicate_paths(&mut self.allow_read);
246 deduplicate_paths(&mut self.deny_read);
247 deduplicate_paths(&mut self.allow_write);
248 remove_redundant_descendants(&mut self.deny_write);
249 deduplicate_paths(&mut self.write_exceptions);
250 deduplicate_paths(&mut self.allow_unix_sockets);
251 deduplicate_paths(&mut self.mount_roots);
252 Ok(())
253 }
254
255 fn apply_mount(&mut self, mount: &crate::policy::FilesystemMount) -> Result<()> {
256 use crate::policy::MountMode;
257 let path = self.resolve_overlay_path(&mount.root, OverlayKind::Allow)?;
258 self.mount_roots.push(path.clone());
259 match mount.mode {
260 MountMode::ReadOnly => {
261 self.allow_read.push(path.clone());
263 if self
266 .allow_write
267 .iter()
268 .any(|writable| path.starts_with(writable))
269 {
270 self.deny_write.push(path);
271 }
272 }
273 MountMode::ReadWrite => {
274 self.ensure_within_boundary(&path, "ReadWrite mount")?;
275 self.allow_read.push(path.clone());
276 self.allow_write.push(path);
277 }
278 MountMode::Scratch => {
279 if !path.starts_with(&self.scratch) {
280 bail!(
281 "Scratch mount {} must stay under session scratch {}; fail closed",
282 path.display(),
283 self.scratch.display()
284 );
285 }
286 self.allow_read.push(path.clone());
287 self.allow_write.push(path);
288 }
289 }
290 Ok(())
291 }
292
293 fn resolve_overlay_path(
294 &self,
295 rule: &crate::policy::PathRule,
296 kind: OverlayKind,
297 ) -> Result<PathBuf> {
298 let value = match rule {
299 crate::policy::PathRule::Exact(value) => value,
300 crate::policy::PathRule::Glob(_) => bail!(
301 "glob path rules cannot compile into Gate 1 OS profiles; fail closed \
302 (kind={kind:?})"
303 ),
304 };
305 let candidate = if Path::new(value).is_absolute() {
306 PathBuf::from(value)
307 } else {
308 self.workspace.join(value)
309 };
310 candidate.canonicalize().with_context(|| {
311 format!("failed to resolve policy overlay path {value} (kind={kind:?})")
312 })
313 }
314
315 fn ensure_within_boundary(&self, path: &Path, field: &str) -> Result<()> {
316 if path.starts_with(&self.workspace) || path.starts_with(&self.scratch) {
317 return Ok(());
318 }
319 bail!(
320 "policy {field} overlay {} is outside workspace/scratch and would broaden \
321 the boundary; fail closed",
322 path.display()
323 );
324 }
325
326 pub(crate) fn child_environment(
327 &self,
328 explicit: Option<&HashMap<String, String>>,
329 ) -> Result<BTreeMap<OsString, OsString>> {
330 compose_child_env(
331 explicit,
332 &self.scratch,
333 self.mediator_port,
334 self.mediator_pipe_name.as_deref(),
335 self.socks_mediator_port,
336 )
337 }
338}
339
340#[cfg(any(target_os = "linux", windows))]
341pub(crate) fn requires_directory_placeholder(workspace: &Path, path: &Path) -> bool {
342 let Ok(relative) = path.strip_prefix(workspace) else {
343 return false;
344 };
345 let mut components = relative.components();
346 let Some(component) = components.next() else {
347 return false;
348 };
349 if components.next().is_some() {
350 return false;
351 }
352 let name = component.as_os_str().to_string_lossy();
353 PROTECTED_WORKSPACE_DIRECTORIES
354 .iter()
355 .any(|protected| name.eq_ignore_ascii_case(protected))
356}
357
358fn validate_denied_workspace_entries(workspace: &Path, paths: &[PathBuf]) -> Result<()> {
359 for path in paths.iter().filter(|path| path.starts_with(workspace)) {
360 match std::fs::symlink_metadata(path) {
361 Ok(metadata) if metadata.file_type().is_symlink() => {
362 bail!(
363 "native sandbox refuses a symbolic link at protected workspace path {}",
364 path.display()
365 );
366 }
367 Ok(_) => {}
368 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
369 Err(error) => {
370 return Err(error).with_context(|| {
371 format!(
372 "failed to inspect protected workspace path {}",
373 path.display()
374 )
375 });
376 }
377 }
378 }
379 Ok(())
380}
381
382const SAFE_ENV_KEYS: &[&str] = &[
384 "PATH",
385 "USER",
386 "USERNAME",
387 "LOGNAME",
388 "SHELL",
389 "LANG",
390 "LC_ALL",
391 "LC_CTYPE",
392 "TZ",
393 "TERM",
394 "COLORTERM",
395 "NO_COLOR",
396 "CI",
397 "CARGO_HOME",
398 "RUSTUP_HOME",
399 "RUSTC_WRAPPER",
400 "GOPATH",
401 "GOROOT",
402 "GOMODCACHE",
403 "NVM_DIR",
404 "FNM_DIR",
405 "VOLTA_HOME",
406 "BUN_INSTALL",
407 "DENO_DIR",
408 "PNPM_HOME",
409 "JAVA_HOME",
410 "GRADLE_USER_HOME",
411 "MAVEN_HOME",
412 "SDKROOT",
413 "DEVELOPER_DIR",
414 "PKG_CONFIG_PATH",
415 "LIBRARY_PATH",
416 "CPATH",
417 "CC",
418 "CXX",
419 "AR",
420 "SYSTEMROOT",
421 "SYSTEMDRIVE",
422 "WINDIR",
423 "COMSPEC",
424 "PATHEXT",
425 "PSMODULEPATH",
426 "PROGRAMDATA",
427 "PROGRAMFILES",
428 "PROGRAMFILES(X86)",
429 "PROGRAMW6432",
430 "COMMONPROGRAMFILES",
431 "COMMONPROGRAMFILES(X86)",
432 "COMMONPROGRAMW6432",
433 "PROCESSOR_ARCHITECTURE",
434 "NUMBER_OF_PROCESSORS",
435 "OS",
436 "HOMEDRIVE",
437 "HOMEPATH",
438 "PUBLIC",
439 "ALLUSERSPROFILE",
440];
441
442const REHOME_ENV_KEYS: &[&str] = &[
444 "HOME",
445 "USERPROFILE",
446 "APPDATA",
447 "LOCALAPPDATA",
448 "TMPDIR",
449 "TMP",
450 "TEMP",
451 "XDG_CACHE_HOME",
452 "XDG_CONFIG_HOME",
453 "XDG_DATA_HOME",
454 "XDG_STATE_HOME",
455];
456
457fn compose_child_env(
458 explicit: Option<&HashMap<String, String>>,
459 scratch: &Path,
460 mediator_port: Option<u16>,
461 mediator_pipe_name: Option<&str>,
462 socks_mediator_port: Option<u16>,
463) -> Result<BTreeMap<OsString, OsString>> {
464 let mut environment = BTreeMap::new();
465 for key in SAFE_ENV_KEYS {
466 if let Some(value) = std::env::var_os(key) {
467 environment.insert(OsString::from(key), value);
468 }
469 }
470 for (key, value) in std::env::vars_os() {
471 if key.to_string_lossy().starts_with("LC_") {
472 environment.insert(key, value);
473 }
474 }
475 if let Some(explicit) = explicit {
476 for (key, value) in explicit {
477 if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') {
478 bail!("invalid explicit command environment entry: {key:?}");
479 }
480 environment.insert(OsString::from(key), OsString::from(value));
481 }
482 }
483 remove_bootstrap_injection_variables(&mut environment);
484 scrub_proxy_environment(&mut environment);
485 environment.retain(|key, _| {
486 let key = key.to_string_lossy();
487 !key.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE")
488 && !key.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE_HANDLE")
489 });
490 if let Some(port) = mediator_port {
491 let proxy = OsString::from(format!("http://127.0.0.1:{port}"));
492 for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] {
493 environment.insert(OsString::from(key), proxy.clone());
494 }
495 if socks_mediator_port.is_none() {
497 for key in ["ALL_PROXY", "all_proxy"] {
498 environment.insert(OsString::from(key), proxy.clone());
499 }
500 }
501 environment.insert(OsString::from("NO_PROXY"), OsString::from(""));
503 environment.insert(OsString::from("no_proxy"), OsString::from(""));
504 }
505 if let Some(pipe_name) = mediator_pipe_name {
506 environment.insert(
509 OsString::from("A3S_SANDBOX_MEDIATOR_PIPE"),
510 OsString::from(pipe_name),
511 );
512 }
513 if let Some(port) = socks_mediator_port {
514 let proxy = OsString::from(format!("socks5://127.0.0.1:{port}"));
515 for key in ["ALL_PROXY", "all_proxy"] {
516 environment.insert(OsString::from(key), proxy.clone());
517 }
518 environment.insert(OsString::from("NO_PROXY"), OsString::from(""));
519 environment.insert(OsString::from("no_proxy"), OsString::from(""));
520 }
521
522 let scratch = scratch.as_os_str().to_os_string();
523 for key in REHOME_ENV_KEYS {
524 environment.insert(OsString::from(key), scratch.clone());
525 }
526 Ok(environment)
527}
528
529const PROXY_ENV_KEYS: &[&str] = &[
531 "HTTP_PROXY",
532 "HTTPS_PROXY",
533 "ALL_PROXY",
534 "NO_PROXY",
535 "http_proxy",
536 "https_proxy",
537 "all_proxy",
538 "no_proxy",
539 "FTP_PROXY",
540 "ftp_proxy",
541];
542
543fn scrub_proxy_environment(environment: &mut BTreeMap<OsString, OsString>) {
544 environment.retain(|key, _| {
545 let key = key.to_string_lossy();
546 !PROXY_ENV_KEYS
547 .iter()
548 .any(|blocked| key.eq_ignore_ascii_case(blocked))
549 });
550}
551
552const BOOTSTRAP_INJECTION_KEYS: &[&str] = &[
555 "BASH_ENV",
556 "ENV",
557 "NODE_OPTIONS",
558 "NODE_PATH",
559 "PYTHONHOME",
560 "PYTHONPATH",
561 "PYTHONSTARTUP",
562 "PYTHONINSPECT",
563 "RUBYOPT",
564 "RUBYLIB",
565 "PERL5OPT",
566 "PERL5LIB",
567 "LUA_INIT",
568 "JAVA_TOOL_OPTIONS",
569 "JDK_JAVA_OPTIONS",
570 "_JAVA_OPTIONS",
571 "LD_PRELOAD",
572 "LD_LIBRARY_PATH",
573 "DYLD_INSERT_LIBRARIES",
574 "DYLD_LIBRARY_PATH",
575];
576
577fn remove_bootstrap_injection_variables(environment: &mut BTreeMap<OsString, OsString>) {
578 environment.retain(|key, _| {
579 let key = key.to_string_lossy();
580 !BOOTSTRAP_INJECTION_KEYS
581 .iter()
582 .any(|blocked| key.eq_ignore_ascii_case(blocked))
583 && !key.to_ascii_uppercase().starts_with("LUA_INIT_")
584 });
585}
586
587pub(crate) fn secret_env_name_is_reserved(name: &str) -> bool {
591 [
592 REHOME_ENV_KEYS,
593 PROXY_ENV_KEYS,
594 SAFE_ENV_KEYS,
595 BOOTSTRAP_INJECTION_KEYS,
596 ]
597 .into_iter()
598 .any(|group| group.iter().any(|key| name.eq_ignore_ascii_case(key)))
599 || name.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE")
600 || name.eq_ignore_ascii_case("A3S_SANDBOX_MEDIATOR_PIPE_HANDLE")
601}
602
603pub(crate) fn resolve_executable(
604 binary: impl Into<PathBuf>,
605 excluded_root: &Path,
606) -> Result<PathBuf> {
607 let excluded_root = excluded_root.canonicalize().with_context(|| {
613 format!(
614 "failed to resolve native sandbox workspace while validating executable: {}",
615 excluded_root.display()
616 )
617 })?;
618 let binary = binary.into();
619 let candidate = if binary.components().count() == 1 {
620 find_executable_on_path(&binary, &excluded_root).ok_or_else(|| {
621 anyhow::anyhow!(
622 "required native sandbox executable was not found on PATH: {}",
623 binary.display()
624 )
625 })?
626 } else {
627 binary
628 };
629 let candidate = candidate
630 .canonicalize()
631 .with_context(|| format!("failed to resolve executable {}", candidate.display()))?;
632 if !candidate.is_file() || !is_executable(&candidate) {
633 bail!(
634 "native sandbox executable is not executable: {}",
635 candidate.display()
636 );
637 }
638 if candidate.starts_with(&excluded_root) {
639 bail!(
640 "refusing native sandbox executable from inside the active workspace: {}",
641 candidate.display()
642 );
643 }
644 Ok(candidate)
645}
646
647fn find_executable_on_path(binary: &Path, excluded_root: &Path) -> Option<PathBuf> {
648 let path = std::env::var_os("PATH")?;
649 for directory in std::env::split_paths(&path) {
650 if !directory.is_absolute() {
651 continue;
652 }
653 let candidate = directory.join(binary);
654 if executable_is_trusted(&candidate, excluded_root) {
655 return candidate.canonicalize().ok();
656 }
657 #[cfg(windows)]
658 for extension in executable_extensions() {
659 let mut name = binary.as_os_str().to_os_string();
660 name.push(extension);
661 let candidate = directory.join(name);
662 if executable_is_trusted(&candidate, excluded_root) {
663 return candidate.canonicalize().ok();
664 }
665 }
666 }
667 None
668}
669
670fn executable_is_trusted(candidate: &Path, excluded_root: &Path) -> bool {
671 if !candidate.is_file() || !is_executable(candidate) {
672 return false;
673 }
674 candidate
675 .canonicalize()
676 .is_ok_and(|resolved| !resolved.starts_with(excluded_root))
677}
678
679#[cfg(windows)]
680fn executable_extensions() -> Vec<OsString> {
681 std::env::var_os("PATHEXT")
682 .map(|value| {
683 value
684 .to_string_lossy()
685 .split(';')
686 .filter(|value| !value.is_empty())
687 .map(OsString::from)
688 .collect()
689 })
690 .unwrap_or_else(|| {
691 [".COM", ".EXE", ".BAT", ".CMD"]
692 .into_iter()
693 .map(OsString::from)
694 .collect()
695 })
696}
697
698fn is_executable(path: &Path) -> bool {
699 #[cfg(unix)]
700 {
701 use std::os::unix::fs::PermissionsExt;
702 path.metadata()
703 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
704 .unwrap_or(false)
705 }
706 #[cfg(not(unix))]
707 {
708 path.is_file()
709 }
710}
711
712pub fn sensitive_paths() -> Vec<PathBuf> {
714 let mut paths = dirs::home_dir()
715 .map(|home| default_sensitive_paths(&home))
716 .unwrap_or_default();
717
718 extend_configured_secret(&mut paths, "CODEX_HOME", Some("auth.json"));
719 extend_configured_secret(&mut paths, "CLAUDE_CONFIG_DIR", Some(".credentials.json"));
720 extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials"));
721 extend_configured_secret(&mut paths, "CARGO_HOME", Some("credentials.toml"));
722 for variable in ["A3S_KIMI_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR"] {
723 extend_configured_secret(&mut paths, variable, Some("credentials/kimi-code.json"));
724 }
725 for variable in [
726 "A3S_KIMI_DESKTOP_HOME",
727 "KIMI_DESKTOP_HOME",
728 "WORKBUDDY_CONFIG_DIR",
729 "CODEBUDDY_CONFIG_DIR",
730 ] {
731 extend_configured_secret(&mut paths, variable, None);
732 }
733 paths
734}
735
736fn read_denied_roots() -> Vec<PathBuf> {
737 let mut roots = Vec::new();
738 if let Some(home) = dirs::home_dir() {
739 roots.push(home.canonicalize().unwrap_or(home));
740 }
741 let temp = std::env::temp_dir();
742 roots.push(temp.canonicalize().unwrap_or(temp));
743 roots
744}
745
746fn readable_tool_paths(workspace: &Path, scratch: &Path) -> Vec<PathBuf> {
747 const TOOLCHAIN_ROOTS: &[&str] = &[
748 "CARGO_HOME",
749 "RUSTUP_HOME",
750 "GOPATH",
751 "GOROOT",
752 "GOMODCACHE",
753 "NVM_DIR",
754 "FNM_DIR",
755 "VOLTA_HOME",
756 "BUN_INSTALL",
757 "DENO_DIR",
758 "PNPM_HOME",
759 "JAVA_HOME",
760 "GRADLE_USER_HOME",
761 "MAVEN_HOME",
762 "SDKROOT",
763 "DEVELOPER_DIR",
764 ];
765
766 let mut paths = vec![workspace.to_path_buf(), scratch.to_path_buf()];
767 for variable in TOOLCHAIN_ROOTS {
768 let Some(path) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
769 continue;
770 };
771 let path = PathBuf::from(path);
772 if path.is_absolute() && path.exists() {
773 paths.push(path.canonicalize().unwrap_or(path));
774 }
775 }
776 if let Some(path) = std::env::var_os("PATH") {
777 paths.extend(std::env::split_paths(&path).filter_map(|path| {
778 if !path.is_absolute() || !path.exists() {
779 return None;
780 }
781 path.canonicalize().ok()
782 }));
783 }
784 paths
785}
786
787fn default_sensitive_paths(home: &Path) -> Vec<PathBuf> {
788 [
789 ".ssh",
790 ".gnupg",
791 ".aws",
792 ".azure",
793 ".kube",
794 ".docker",
795 ".config/gcloud",
796 ".config/gh",
797 ".netrc",
798 ".npmrc",
799 ".pypirc",
800 ".cargo/credentials",
801 ".cargo/credentials.toml",
802 ".codex/auth.json",
803 ".claude/.credentials.json",
804 ".claude.json",
805 ".git-credentials",
806 ".config/git/credentials",
807 ".workbuddy",
808 ".workbuddy-ai",
809 "credentials/kimi-code.json",
810 ".kimi-code/credentials/kimi-code.json",
811 ".kimi/credentials/kimi-code.json",
812 ".config/kimi-desktop/daimon-share",
813 "Library/Application Support/kimi-desktop/daimon-share",
814 ".config/opencode/auth.json",
815 ".local/share/opencode/auth.json",
816 ".gemini/oauth_creds.json",
817 ".terraform.d/credentials.tfrc.json",
818 ".local/share/keyrings",
819 ".password-store",
820 ".a3s/os-auth.json",
821 "Library/Keychains",
822 ]
823 .into_iter()
824 .map(|path| home.join(path))
825 .collect()
826}
827
828const FIXED_WORKSPACE_SECRET_FILES: &[&str] = &[
829 ".env",
830 ".env.local",
831 ".env.development",
832 ".env.production",
833 ".env.test",
834 ".netrc",
835 ".npmrc",
836 ".pypirc",
837 ".git-credentials",
838 ".a3s/os-auth.json",
839 ".codex/auth.json",
840 ".claude/.credentials.json",
841 ".claude.json",
842];
843
844fn fixed_workspace_secret_paths(workspace: &Path) -> Vec<PathBuf> {
845 FIXED_WORKSPACE_SECRET_FILES
846 .iter()
847 .map(|path| workspace.join(path))
848 .collect()
849}
850
851pub fn workspace_sensitive_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
853 let mut paths = fixed_workspace_secret_paths(workspace);
854 paths.extend(scan_workspace_security(workspace)?.nested_env);
855 Ok(paths)
856}
857
858pub fn workspace_hardlink_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
865 let mut hardlinks = scan_workspace_security(workspace)?.source_hardlinks;
866 deduplicate_paths(&mut hardlinks);
867 Ok(hardlinks)
868}
869
870#[derive(Debug, Default)]
871struct WorkspaceSecurityScan {
872 nested_env: Vec<PathBuf>,
873 source_hardlinks: Vec<PathBuf>,
874}
875
876fn scan_workspace_security(workspace: &Path) -> Result<WorkspaceSecurityScan> {
878 let mut pending = vec![(workspace.to_path_buf(), 0usize, true)];
879 let mut scanned = 0usize;
880 let mut scan = WorkspaceSecurityScan::default();
881
882 while let Some((directory, depth, collect_hardlinks)) = pending.pop() {
883 let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
884 format!(
885 "failed to scan native sandbox workspace {}",
886 directory.display()
887 )
888 })?
889 else {
890 continue;
891 };
892 for entry in entries {
893 let Some(entry) = workspace_scan_result(entry, || {
894 format!(
895 "failed to enumerate native sandbox workspace {}",
896 directory.display()
897 )
898 })?
899 else {
900 continue;
901 };
902 scanned = next_workspace_scan_entry(scanned, MAX_WORKSPACE_SCAN_ENTRIES)?;
903 let path = entry.path();
904 let file_name = entry.file_name();
905 let Some(file_type) = workspace_scan_result(entry.file_type(), || {
906 format!(
907 "failed to inspect native sandbox workspace path {}",
908 path.display()
909 )
910 })?
911 else {
912 continue;
913 };
914
915 if file_name.to_str().is_some_and(|name| {
916 name.get(..4)
917 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(".env"))
918 }) {
919 scan.nested_env.push(path.clone());
920 }
921
922 if file_type.is_symlink() {
923 continue;
924 }
925 if file_type.is_dir() {
926 if should_skip_workspace_scan_directory(&file_name) {
927 continue;
928 }
929 ensure_workspace_scan_depth(depth, &path)?;
930 let child_collect_hardlinks =
931 collect_hardlinks && !is_protected_workspace_directory(&file_name);
932 pending.push((path, depth + 1, child_collect_hardlinks));
933 continue;
934 }
935 if !collect_hardlinks || !file_type.is_file() {
936 continue;
937 }
938 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
939 format!(
940 "failed to inspect native sandbox workspace path {}",
941 path.display()
942 )
943 })?
944 else {
945 continue;
946 };
947 if metadata.file_type().is_symlink() || !metadata.is_file() {
948 continue;
949 }
950 if hard_link_count(&path, &metadata) > 1 {
951 scan.source_hardlinks.push(path);
952 }
953 }
954 }
955 Ok(scan)
956}
957
958pub fn workspace_credential_hardlink_aliases(
965 workspace: &Path,
966 sensitive: &[PathBuf],
967) -> Result<Vec<PathBuf>> {
968 let mut wanted = HashSet::new();
969 for path in sensitive {
970 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(path), || {
971 format!(
972 "failed to inspect native sandbox credential path {}",
973 path.display()
974 )
975 })?
976 else {
977 continue;
978 };
979 if metadata.file_type().is_symlink() || !metadata.is_file() {
980 continue;
981 }
982 if hard_link_count(path, &metadata) <= 1 {
983 continue;
984 }
985 let Some(identity) = FileIdentity::from_path(path, &metadata) else {
986 continue;
987 };
988 wanted.insert(identity);
989 }
990 if wanted.is_empty() {
991 return Ok(Vec::new());
992 }
993
994 let mut pending = vec![(workspace.to_path_buf(), 0usize, false)];
995 let mut scanned = 0usize;
996 let mut aliases = Vec::new();
997 let sensitive_set: HashSet<&Path> = sensitive.iter().map(PathBuf::as_path).collect();
998
999 while let Some((directory, depth, in_package_store)) = pending.pop() {
1000 let Some(entries) = workspace_scan_result(std::fs::read_dir(&directory), || {
1001 format!(
1002 "failed to scan native sandbox package stores under {}",
1003 directory.display()
1004 )
1005 })?
1006 else {
1007 continue;
1008 };
1009 for entry in entries {
1010 let Some(entry) = workspace_scan_result(entry, || {
1011 format!(
1012 "failed to enumerate native sandbox package stores under {}",
1013 directory.display()
1014 )
1015 })?
1016 else {
1017 continue;
1018 };
1019 scanned = next_workspace_scan_entry(scanned, MAX_CREDENTIAL_ALIAS_SCAN_ENTRIES)?;
1020 let path = entry.path();
1021 let file_name = entry.file_name();
1022 let Some(file_type) = workspace_scan_result(entry.file_type(), || {
1023 format!(
1024 "failed to inspect native sandbox package-store path {}",
1025 path.display()
1026 )
1027 })?
1028 else {
1029 continue;
1030 };
1031 if file_type.is_symlink() {
1032 continue;
1033 }
1034 if file_type.is_dir() {
1035 if !in_package_store && is_git_directory(&file_name) {
1036 continue;
1037 }
1038 let child_in_store =
1039 in_package_store || is_package_or_build_store_directory(&file_name);
1040 if !child_in_store && is_protected_workspace_directory(&file_name) {
1041 continue;
1042 }
1043 ensure_workspace_scan_depth(depth, &path)?;
1044 pending.push((path, depth + 1, child_in_store));
1045 continue;
1046 }
1047 if !in_package_store || !file_type.is_file() {
1048 continue;
1049 }
1050 if sensitive_set.contains(path.as_path()) {
1051 continue;
1052 }
1053 let Some(metadata) = workspace_scan_result(std::fs::symlink_metadata(&path), || {
1054 format!(
1055 "failed to inspect native sandbox package-store path {}",
1056 path.display()
1057 )
1058 })?
1059 else {
1060 continue;
1061 };
1062 if metadata.file_type().is_symlink() || !metadata.is_file() {
1063 continue;
1064 }
1065 if hard_link_count(&path, &metadata) <= 1 {
1066 continue;
1067 }
1068 let Some(identity) = FileIdentity::from_path(&path, &metadata) else {
1069 continue;
1070 };
1071 if wanted.contains(&identity) {
1072 aliases.push(path);
1073 }
1074 }
1075 }
1076 deduplicate_paths(&mut aliases);
1077 Ok(aliases)
1078}
1079
1080fn workspace_scan_result<T>(
1081 result: std::io::Result<T>,
1082 context: impl FnOnce() -> String,
1083) -> Result<Option<T>> {
1084 match result {
1085 Ok(value) => Ok(Some(value)),
1086 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1087 Err(error) => Err(error).with_context(context),
1088 }
1089}
1090
1091fn next_workspace_scan_entry(scanned: usize, limit: usize) -> Result<usize> {
1092 let scanned = scanned
1093 .checked_add(1)
1094 .context("native sandbox workspace scan entry count overflowed")?;
1095 if scanned > limit {
1096 bail!("native sandbox workspace exceeds the {limit} entry scan limit");
1097 }
1098 Ok(scanned)
1099}
1100
1101fn ensure_workspace_scan_depth(depth: usize, path: &Path) -> Result<()> {
1102 if depth >= MAX_WORKSPACE_SCAN_DEPTH {
1103 bail!(
1104 "native sandbox workspace exceeds the {MAX_WORKSPACE_SCAN_DEPTH}-level scan depth at {}",
1105 path.display()
1106 );
1107 }
1108 Ok(())
1109}
1110
1111pub fn should_skip_workspace_scan_directory(name: &OsStr) -> bool {
1114 is_git_directory(name) || is_package_or_build_store_directory(name)
1115}
1116
1117fn is_package_or_build_store_directory(name: &OsStr) -> bool {
1118 name.to_str().is_some_and(|name| {
1119 ["node_modules", "target"]
1120 .iter()
1121 .any(|skipped| name.eq_ignore_ascii_case(skipped))
1122 })
1123}
1124
1125fn is_git_directory(name: &OsStr) -> bool {
1126 name.to_str()
1127 .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
1128}
1129
1130fn is_protected_workspace_directory(name: &OsStr) -> bool {
1131 name.to_str().is_some_and(|name| {
1132 PROTECTED_WORKSPACE_DIRECTORIES
1133 .iter()
1134 .any(|protected| name.eq_ignore_ascii_case(protected))
1135 })
1136}
1137
1138#[cfg(unix)]
1139#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
1140struct FileIdentity {
1141 device: u64,
1142 inode: u64,
1143}
1144
1145#[cfg(unix)]
1146impl FileIdentity {
1147 fn from_path(_path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
1148 use std::os::unix::fs::MetadataExt;
1149 Some(Self {
1150 device: metadata.dev(),
1151 inode: metadata.ino(),
1152 })
1153 }
1154}
1155
1156#[cfg(windows)]
1157#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
1158struct FileIdentity {
1159 volume: u32,
1160 index: u64,
1161}
1162
1163#[cfg(windows)]
1164impl FileIdentity {
1165 fn from_path(path: &Path, _metadata: &std::fs::Metadata) -> Option<Self> {
1166 use std::os::windows::io::AsRawHandle;
1167 use windows_sys::Win32::Storage::FileSystem::{
1168 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1169 };
1170
1171 let file = std::fs::File::open(path).ok()?;
1172 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
1173 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
1175 return None;
1176 }
1177 Some(Self {
1178 volume: information.dwVolumeSerialNumber,
1179 index: (u64::from(information.nFileIndexHigh) << 32)
1180 | u64::from(information.nFileIndexLow),
1181 })
1182 }
1183}
1184
1185#[cfg(not(any(unix, windows)))]
1186#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
1187struct FileIdentity;
1188
1189#[cfg(not(any(unix, windows)))]
1190impl FileIdentity {
1191 fn from_path(_path: &Path, _metadata: &std::fs::Metadata) -> Option<Self> {
1192 None
1193 }
1194}
1195
1196#[cfg(unix)]
1197pub fn hard_link_count(_path: &Path, metadata: &std::fs::Metadata) -> u64 {
1200 use std::os::unix::fs::MetadataExt;
1201 metadata.nlink()
1202}
1203
1204#[cfg(windows)]
1205pub fn hard_link_count(path: &Path, metadata: &std::fs::Metadata) -> u64 {
1208 let Ok(file) = std::fs::File::open(path) else {
1209 return u64::MAX;
1210 };
1211 hard_link_count_for_open_file(&file, metadata)
1212}
1213
1214#[cfg(windows)]
1215pub fn hard_link_count_for_open_file<T>(file: &T, _metadata: &std::fs::Metadata) -> u64
1217where
1218 T: std::os::windows::io::AsRawHandle,
1219{
1220 use windows_sys::Win32::Storage::FileSystem::{
1221 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1222 };
1223
1224 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
1225 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
1227 return u64::MAX;
1228 }
1229 u64::from(information.nNumberOfLinks.max(1))
1230}
1231
1232#[cfg(unix)]
1234pub fn hard_link_count_for_open_file<T>(_file: &T, metadata: &std::fs::Metadata) -> u64 {
1235 use std::os::unix::fs::MetadataExt;
1236 metadata.nlink()
1237}
1238
1239#[cfg(not(any(unix, windows)))]
1240pub fn hard_link_count(_path: &Path, _metadata: &std::fs::Metadata) -> u64 {
1242 1
1243}
1244
1245#[cfg(not(any(unix, windows)))]
1246pub fn hard_link_count_for_open_file<T>(_file: &T, _metadata: &std::fs::Metadata) -> u64 {
1248 1
1249}
1250
1251fn extend_configured_secret(paths: &mut Vec<PathBuf>, variable: &str, suffix: Option<&str>) {
1252 let Some(root) = std::env::var_os(variable).filter(|value| !value.is_empty()) else {
1253 return;
1254 };
1255 let root = PathBuf::from(root);
1256 if !root.is_absolute() {
1257 return;
1258 }
1259 paths.push(match suffix {
1260 Some(suffix) => root.join(suffix),
1261 None => root,
1262 });
1263}
1264
1265fn protected_workspace_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
1266 let mut paths = PROTECTED_WORKSPACE_DIRECTORIES
1267 .iter()
1268 .chain(PROTECTED_WORKSPACE_FILES)
1269 .copied()
1270 .map(|path| workspace.join(path))
1271 .collect::<Vec<_>>();
1272
1273 let entries = std::fs::read_dir(workspace).with_context(|| {
1278 format!(
1279 "failed to scan protected workspace roots {}",
1280 workspace.display()
1281 )
1282 })?;
1283 for entry in entries {
1284 let entry = entry.with_context(|| {
1285 format!(
1286 "failed to enumerate protected workspace roots {}",
1287 workspace.display()
1288 )
1289 })?;
1290 let name = entry.file_name();
1291 if PROTECTED_WORKSPACE_DIRECTORIES
1292 .iter()
1293 .chain(PROTECTED_WORKSPACE_FILES)
1294 .any(|protected| {
1295 name.to_str()
1296 .is_some_and(|name| name.eq_ignore_ascii_case(protected))
1297 })
1298 {
1299 paths.push(entry.path());
1300 }
1301 }
1302 Ok(paths)
1303}
1304
1305fn resolved_git_dir(workspace: &Path) -> Option<PathBuf> {
1306 let dot_git = workspace.join(".git");
1307 let dot_git = if dot_git.exists() {
1308 dot_git
1309 } else {
1310 std::fs::read_dir(workspace)
1311 .ok()?
1312 .filter_map(Result::ok)
1313 .find(|entry| {
1314 entry
1315 .file_name()
1316 .to_str()
1317 .is_some_and(|name| name.eq_ignore_ascii_case(".git"))
1318 })
1319 .map(|entry| entry.path())?
1320 };
1321 if dot_git.is_dir() {
1322 return dot_git.canonicalize().ok();
1323 }
1324 let source = std::fs::read_to_string(dot_git).ok()?;
1325 let relative = source.trim().strip_prefix("gitdir:")?.trim();
1326 let path = Path::new(relative);
1327 let path = if path.is_absolute() {
1328 path.to_path_buf()
1329 } else {
1330 workspace.join(path)
1331 };
1332 path.canonicalize().ok()
1333}
1334
1335fn expand_existing_canonical_paths(paths: &mut Vec<PathBuf>) {
1336 let resolved = paths
1337 .iter()
1338 .filter_map(|path| path.canonicalize().ok())
1339 .collect::<Vec<_>>();
1340 paths.extend(resolved);
1341 deduplicate_paths(paths);
1342}
1343
1344pub(crate) fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
1345 paths.sort();
1346 paths.dedup();
1347}
1348
1349fn remove_redundant_descendants(paths: &mut Vec<PathBuf>) {
1350 deduplicate_paths(paths);
1351 let candidates = paths.clone();
1352 paths.retain(|path| {
1353 !candidates
1354 .iter()
1355 .any(|ancestor| ancestor != path && path.starts_with(ancestor))
1356 });
1357}
1358
1359#[cfg(any(target_os = "linux", target_os = "macos"))]
1360pub(crate) fn path_ancestors(path: &Path) -> Vec<PathBuf> {
1361 let mut ancestors = path
1362 .parent()
1363 .into_iter()
1364 .flat_map(Path::ancestors)
1365 .take_while(|ancestor| ancestor.parent().is_some())
1366 .map(Path::to_path_buf)
1367 .collect::<Vec<_>>();
1368 ancestors.reverse();
1369 ancestors
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375
1376 #[test]
1377 fn child_environment_removes_runtime_injection_and_rehomes_state() {
1378 let scratch = tempfile::tempdir().unwrap();
1379 let explicit = HashMap::from([
1380 ("SAFE_VALUE".to_string(), "visible".to_string()),
1381 ("BASH_ENV".to_string(), "/tmp/attack".to_string()),
1382 ("LD_PRELOAD".to_string(), "/tmp/attack.so".to_string()),
1383 ]);
1384 let environment =
1385 compose_child_env(Some(&explicit), scratch.path(), None, None, None).unwrap();
1386
1387 assert_eq!(
1388 environment.get(OsStr::new("SAFE_VALUE")),
1389 Some(&OsString::from("visible"))
1390 );
1391 assert!(!environment.contains_key(OsStr::new("BASH_ENV")));
1392 assert!(!environment.contains_key(OsStr::new("LD_PRELOAD")));
1393 assert_eq!(
1394 environment.get(OsStr::new("HOME")),
1395 Some(&scratch.path().as_os_str().to_os_string())
1396 );
1397 }
1398
1399 #[test]
1400 fn child_environment_removes_case_insensitive_bootstrap_variables() {
1401 let scratch = tempfile::tempdir().unwrap();
1402 let explicit = HashMap::from([
1403 ("bash_env".to_string(), "attack".to_string()),
1404 ("Ld_PreLoad".to_string(), "attack.so".to_string()),
1405 ("LUA_INIT_script".to_string(), "attack.lua".to_string()),
1406 ("SAFE_VALUE".to_string(), "visible".to_string()),
1407 ]);
1408 let environment =
1409 compose_child_env(Some(&explicit), scratch.path(), None, None, None).unwrap();
1410
1411 assert!(!environment.keys().any(|key| {
1412 matches!(
1413 key.to_string_lossy().to_ascii_uppercase().as_str(),
1414 "BASH_ENV" | "LD_PRELOAD"
1415 ) || key
1416 .to_string_lossy()
1417 .to_ascii_uppercase()
1418 .starts_with("LUA_INIT_")
1419 }));
1420 assert_eq!(
1421 environment.get(OsStr::new("SAFE_VALUE")),
1422 Some(&OsString::from("visible"))
1423 );
1424 }
1425
1426 #[test]
1427 fn child_environment_mediator_overwrites_explicit_proxy_bypass() {
1428 let scratch = tempfile::tempdir().unwrap();
1429 let explicit = HashMap::from([
1430 ("NO_PROXY".to_string(), "*".to_string()),
1431 (
1432 "HTTPS_PROXY".to_string(),
1433 "http://evil.example:9".to_string(),
1434 ),
1435 ("FTP_PROXY".to_string(), "http://evil.example:9".to_string()),
1436 (
1437 "ALL_PROXY".to_string(),
1438 "socks5://evil.example:9".to_string(),
1439 ),
1440 ]);
1441 let environment =
1442 compose_child_env(Some(&explicit), scratch.path(), Some(18080), None, None).unwrap();
1443 assert_eq!(
1444 environment.get(OsStr::new("HTTPS_PROXY")),
1445 Some(&OsString::from("http://127.0.0.1:18080"))
1446 );
1447 assert_eq!(
1448 environment.get(OsStr::new("ALL_PROXY")),
1449 Some(&OsString::from("http://127.0.0.1:18080"))
1450 );
1451 assert_eq!(
1452 environment.get(OsStr::new("NO_PROXY")),
1453 Some(&OsString::from(""))
1454 );
1455 assert!(!environment.contains_key(OsStr::new("FTP_PROXY")));
1456 }
1457
1458 #[test]
1459 fn child_environment_socks_mediator_sets_all_proxy_only() {
1460 let scratch = tempfile::tempdir().unwrap();
1461 let environment = compose_child_env(None, scratch.path(), None, None, Some(19090)).unwrap();
1462 assert_eq!(
1463 environment.get(OsStr::new("ALL_PROXY")),
1464 Some(&OsString::from("socks5://127.0.0.1:19090"))
1465 );
1466 assert!(!environment.contains_key(OsStr::new("HTTPS_PROXY")));
1467 assert_eq!(
1468 environment.get(OsStr::new("NO_PROXY")),
1469 Some(&OsString::from(""))
1470 );
1471 }
1472
1473 #[test]
1474 fn child_environment_mediator_pipe_sets_named_pipe_not_http_proxy() {
1475 let scratch = tempfile::tempdir().unwrap();
1476 let pipe = r"\\.\pipe\a3s-sandbox-test";
1477 let environment = compose_child_env(None, scratch.path(), None, Some(pipe), None).unwrap();
1478 assert_eq!(
1479 environment.get(OsStr::new("A3S_SANDBOX_MEDIATOR_PIPE")),
1480 Some(&OsString::from(pipe))
1481 );
1482 assert!(
1483 !environment.contains_key(OsStr::new("HTTP_PROXY"))
1484 && !environment.contains_key(OsStr::new("HTTPS_PROXY"))
1485 && !environment.contains_key(OsStr::new("ALL_PROXY")),
1486 "Windows named-pipe bridge must not invent loopback HTTP_PROXY"
1487 );
1488 }
1489
1490 #[test]
1491 fn protected_path_matching_is_case_insensitive_and_traversal_safe() {
1492 for path in [
1493 ".git/config",
1494 ".GIT/HEAD",
1495 r".a3s\policy.acl",
1496 ".mcp.json",
1497 ".zshrc",
1498 ] {
1499 assert!(is_protected_workspace_path(path), "{path}");
1500 }
1501 for path in [
1502 "src/.git/config",
1503 "../.git/config",
1504 ".gitignore",
1505 "src/main.rs",
1506 ".a3s/loops/goal-1/ACCEPTANCE.md",
1507 r".a3s\loops\goal-1\STATE.md",
1508 ] {
1509 assert!(!is_protected_workspace_path(path), "{path}");
1510 }
1511 }
1512
1513 #[test]
1514 fn policy_discovers_case_variant_control_metadata() {
1515 let workspace = tempfile::tempdir().unwrap();
1516 let scratch = tempfile::tempdir().unwrap();
1517 std::fs::create_dir(workspace.path().join(".GIT")).unwrap();
1518 std::fs::write(workspace.path().join(".MCP.JSON"), "control").unwrap();
1519
1520 let policy = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1521 let workspace = workspace.path().canonicalize().unwrap();
1522 assert!(policy.deny_write.contains(&workspace.join(".GIT")));
1523 assert!(policy.deny_write.contains(&workspace.join(".MCP.JSON")));
1524 }
1525
1526 #[test]
1527 fn git_worktree_pointer_is_resolved_for_case_variant_gitfiles() {
1528 let parent = tempfile::tempdir().unwrap();
1529 let workspace = parent.path().join("workspace");
1530 let git_dir = parent.path().join("git-dir");
1531 std::fs::create_dir(&workspace).unwrap();
1532 std::fs::create_dir(&git_dir).unwrap();
1533 std::fs::write(workspace.join(".GIT"), "gitdir: ../git-dir\n").unwrap();
1534 let scratch = tempfile::tempdir().unwrap();
1535
1536 let policy = EnforcedPolicy::for_execution(&workspace, scratch.path()).unwrap();
1537 assert!(policy.deny_write.contains(&git_dir.canonicalize().unwrap()));
1538 }
1539
1540 #[test]
1541 fn nested_secret_scan_matches_case_variant_environment_files() {
1542 let workspace = tempfile::tempdir().unwrap();
1543 std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1544 std::fs::write(workspace.path().join("src/config/.ENV.local"), "secret").unwrap();
1545
1546 let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1547 assert!(paths.contains(&workspace.path().join("src/config/.ENV.local")));
1548 }
1549
1550 #[test]
1551 fn monorepo_workspace_scan_stays_under_entry_limit() {
1552 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
1553 let workspace = workspace.canonicalize().unwrap();
1554 let scratch = tempfile::tempdir().unwrap();
1555 let start = std::time::Instant::now();
1556 let result = EnforcedPolicy::for_execution(&workspace, scratch.path());
1557 let elapsed = start.elapsed();
1558 match result {
1559 Ok(_) => eprintln!("monorepo_scan_ok elapsed_ms={}", elapsed.as_millis()),
1560 Err(error) => panic!(
1561 "monorepo_scan_failed elapsed_ms={}: {error:#}",
1562 elapsed.as_millis()
1563 ),
1564 }
1565 }
1566
1567 #[test]
1568 fn scan_directory_filter_handles_case_variants() {
1569 for name in [".git", ".GIT", "Node_Modules", "TARGET"] {
1570 assert!(should_skip_workspace_scan_directory(OsStr::new(name)));
1571 }
1572 assert!(!should_skip_workspace_scan_directory(OsStr::new("src")));
1573 }
1574
1575 #[test]
1576 fn nested_environment_files_and_hardlinks_enter_the_deny_set() {
1577 let workspace = tempfile::tempdir().unwrap();
1578 let scratch = tempfile::tempdir().unwrap();
1579 std::fs::create_dir_all(workspace.path().join("nested")).unwrap();
1580 std::fs::write(workspace.path().join("nested/.env.secret"), "secret").unwrap();
1581 let outside = scratch.path().join("outside-secret");
1582 std::fs::write(&outside, "outside").unwrap();
1583 std::fs::hard_link(&outside, workspace.path().join("hardlink-secret")).unwrap();
1584
1585 let policy = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1586 let workspace = workspace.path().canonicalize().unwrap();
1587
1588 assert!(policy
1589 .deny_read
1590 .contains(&workspace.join("nested/.env.secret")));
1591 assert!(policy
1592 .deny_read
1593 .contains(&workspace.join("hardlink-secret")));
1594 assert!(policy
1595 .deny_write
1596 .contains(&workspace.join("hardlink-secret")));
1597 }
1598
1599 #[cfg(any(unix, windows))]
1600 #[test]
1601 fn hardlink_scan_skips_build_and_package_stores() {
1602 let workspace = tempfile::tempdir().unwrap();
1603 let outside = tempfile::tempdir().unwrap();
1604 let source = outside.path().join("source");
1605 std::fs::write(&source, "outside").unwrap();
1606 for directory in ["node_modules", "target", ".a3s"] {
1607 let directory = workspace.path().join(directory);
1608 std::fs::create_dir_all(&directory).unwrap();
1609 std::fs::hard_link(&source, directory.join("linked")).unwrap();
1610 }
1611 std::fs::create_dir_all(workspace.path().join("src")).unwrap();
1612 std::fs::hard_link(&source, workspace.path().join("src/linked")).unwrap();
1613
1614 let hardlinks = workspace_hardlink_paths(workspace.path()).unwrap();
1615 assert_eq!(hardlinks.len(), 1);
1616 assert!(hardlinks[0].ends_with("src/linked"));
1617 assert!(!hardlinks
1618 .iter()
1619 .any(|path| path.ends_with("node_modules/linked")));
1620 assert!(!hardlinks.iter().any(|path| path.ends_with("target/linked")));
1621 assert!(!hardlinks.iter().any(|path| path.ends_with(".a3s/linked")));
1622 }
1623
1624 #[cfg(any(unix, windows))]
1625 #[test]
1626 fn credential_hardlink_aliases_inside_package_stores_enter_the_deny_set() {
1627 let workspace = tempfile::tempdir().unwrap();
1628 let scratch = tempfile::tempdir().unwrap();
1629 let env_path = workspace.path().join(".env");
1630 std::fs::write(&env_path, "SECRET=1").unwrap();
1631 for directory in ["node_modules", "target"] {
1632 let directory = workspace.path().join(directory);
1633 std::fs::create_dir_all(&directory).unwrap();
1634 std::fs::hard_link(&env_path, directory.join("linked-secret")).unwrap();
1635 }
1636 let outside = scratch.path().join("ordinary");
1639 std::fs::write(&outside, "ordinary").unwrap();
1640 std::fs::hard_link(&outside, workspace.path().join("node_modules/ordinary")).unwrap();
1641
1642 let policy = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap();
1643 let workspace = workspace.path().canonicalize().unwrap();
1644
1645 assert!(policy
1646 .deny_write
1647 .contains(&workspace.join("node_modules/linked-secret")));
1648 assert!(policy
1649 .deny_write
1650 .contains(&workspace.join("target/linked-secret")));
1651 assert!(!policy
1652 .deny_write
1653 .contains(&workspace.join("node_modules/ordinary")));
1654 }
1655
1656 #[test]
1657 fn nested_secret_scan_skips_control_and_build_stores() {
1658 let workspace = tempfile::tempdir().unwrap();
1659 std::fs::create_dir_all(workspace.path().join("src/config")).unwrap();
1660 std::fs::create_dir_all(workspace.path().join("node_modules/package")).unwrap();
1661 std::fs::create_dir_all(workspace.path().join("target/debug")).unwrap();
1662 std::fs::create_dir_all(workspace.path().join(".git")).unwrap();
1663 for path in [
1664 "src/config/.env.secret",
1665 "node_modules/package/.env.secret",
1666 "target/debug/.env.secret",
1667 ".git/.env.secret",
1668 ] {
1669 std::fs::write(workspace.path().join(path), "secret").unwrap();
1670 }
1671
1672 let paths = workspace_sensitive_paths(workspace.path()).unwrap();
1673 assert!(paths.contains(&workspace.path().join("src/config/.env.secret")));
1674 assert!(!paths.contains(&workspace.path().join("node_modules/package/.env.secret")));
1675 assert!(!paths.contains(&workspace.path().join("target/debug/.env.secret")));
1676 assert!(!paths.contains(&workspace.path().join(".git/.env.secret")));
1677 }
1678
1679 #[cfg(unix)]
1680 #[test]
1681 fn nested_secret_scan_fails_closed_at_depth_limit() {
1682 let workspace = tempfile::tempdir().unwrap();
1683 let mut current = workspace.path().to_path_buf();
1684 for index in 0..=MAX_WORKSPACE_SCAN_DEPTH {
1685 current.push(format!("level-{index}"));
1686 std::fs::create_dir(¤t).unwrap();
1687 }
1688
1689 let error = workspace_sensitive_paths(workspace.path()).unwrap_err();
1690 assert!(error.to_string().contains("depth"), "{error:#}");
1691 }
1692
1693 #[test]
1694 fn executable_resolution_rejects_workspace_tools() {
1695 let workspace = tempfile::tempdir().unwrap();
1696 let candidate = workspace.path().join("untrusted-tool");
1697 std::fs::write(&candidate, "#!/bin/sh\nexit 0\n").unwrap();
1698 #[cfg(unix)]
1699 {
1700 use std::os::unix::fs::PermissionsExt;
1701 std::fs::set_permissions(&candidate, std::fs::Permissions::from_mode(0o755)).unwrap();
1702 }
1703
1704 let error = resolve_executable(&candidate, workspace.path()).unwrap_err();
1705 assert!(error.to_string().contains("inside the active workspace"));
1706 }
1707
1708 #[cfg(any(target_os = "linux", target_os = "macos"))]
1709 #[test]
1710 fn path_ancestors_exclude_the_filesystem_root() {
1711 let ancestors = path_ancestors(Path::new("/a/b/c"));
1712 assert_eq!(ancestors, vec![PathBuf::from("/a"), PathBuf::from("/a/b")]);
1713 }
1714
1715 #[cfg(any(target_os = "linux", windows))]
1716 #[test]
1717 fn only_protected_workspace_roots_require_directory_placeholders() {
1718 let workspace = Path::new("/workspace");
1719 assert!(requires_directory_placeholder(
1720 workspace,
1721 &workspace.join(".a3s")
1722 ));
1723 assert!(requires_directory_placeholder(
1724 workspace,
1725 &workspace.join(".GIT")
1726 ));
1727 assert!(!requires_directory_placeholder(
1728 workspace,
1729 &workspace.join(".gitmodules")
1730 ));
1731 assert!(!requires_directory_placeholder(
1732 workspace,
1733 &workspace.join(".a3s/os-auth.json")
1734 ));
1735 assert!(!requires_directory_placeholder(
1736 workspace,
1737 Path::new("/outside/.a3s")
1738 ));
1739 }
1740
1741 #[cfg(unix)]
1742 #[test]
1743 fn protected_workspace_symlinks_fail_closed() {
1744 use std::os::unix::fs::symlink;
1745
1746 let workspace = tempfile::tempdir().unwrap();
1747 let scratch = tempfile::tempdir().unwrap();
1748 let outside = tempfile::tempdir().unwrap();
1749 symlink(outside.path(), workspace.path().join(".git")).unwrap();
1750
1751 let error = EnforcedPolicy::for_execution(workspace.path(), scratch.path()).unwrap_err();
1752 assert!(error.to_string().contains("symbolic link"), "{error:#}");
1753 }
1754}