1use std::cell::RefCell;
21use std::collections::{BTreeMap, HashMap};
22use std::ffi::{OsStr, OsString};
23#[cfg(unix)]
24use std::fs::{DirBuilder, OpenOptions};
25#[cfg(unix)]
26use std::io::Write;
27#[cfg(unix)]
28use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
29use std::path::{Path, PathBuf};
30#[cfg(unix)]
31use std::process::Command;
32#[cfg(unix)]
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::{Mutex, OnceLock};
35#[cfg(unix)]
36use std::time::{Duration, Instant};
37
38use portable_pty::CommandBuilder;
39
40use crate::context::AppContext;
41use crate::sandbox_profile::SandboxProfile;
42
43pub const SANDBOX_UNAVAILABLE_EXIT_CODE: i32 = 78;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PrincipalTrust {
48 FirstParty,
49 Untrusted,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum AuthenticatedPrincipal {
55 FirstParty,
57 RouteBind {
59 trust: PrincipalTrust,
60 route_channel: u16,
61 route_epoch: u32,
62 project_root: PathBuf,
63 harness: String,
64 session_id: String,
65 principal_id: Option<String>,
69 },
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum RequestedSandboxTier {
75 Disabled,
76 Native,
77 Host,
78}
79
80pub(crate) type ChildEnvironment = BTreeMap<OsString, OsString>;
81
82#[cfg(unix)]
83const ESCALATION_GRANT_TTL: Duration = Duration::from_secs(120);
84#[cfg(unix)]
85const ESCALATION_DIGEST_TAG: &[u8] = b"aft-escalation-v1";
86#[cfg(unix)]
87const ESCALATION_TIER: &[u8] = b"host";
88
89#[cfg(unix)]
90#[derive(Debug, Clone)]
91struct EscalationGrant {
92 principal: AuthenticatedPrincipal,
93 root: PathBuf,
94 digest: blake3::Hash,
95 expires_at: Instant,
96 consumed: bool,
97 environment: ChildEnvironment,
98}
99
100#[cfg(unix)]
101#[derive(Debug, Default)]
102pub(crate) struct EscalationGrantStore {
103 grants: HashMap<String, EscalationGrant>,
104}
105
106#[cfg(unix)]
107impl EscalationGrantStore {
108 #[cfg(test)]
109 pub(crate) fn len_for_test(&self) -> usize {
110 self.grants.len()
111 }
112}
113
114#[derive(Debug, Clone)]
115pub struct HostEscalationAttempt {
116 pub grant_id: String,
117 pub command: Vec<u8>,
118 pub root: PathBuf,
119 pub cwd: PathBuf,
120 pub shell_path: PathBuf,
121 pub environment: ChildEnvironment,
122}
123
124#[cfg(unix)]
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub(crate) enum EscalationRefusal {
127 Expired,
128 Consumed,
129 DigestMismatch,
130 WrongPrincipal,
131}
132
133#[cfg(unix)]
134impl EscalationRefusal {
135 pub(crate) fn class(self) -> &'static str {
136 match self {
137 Self::Expired => "expired",
138 Self::Consumed => "consumed",
139 Self::DigestMismatch => "digest_mismatch",
140 Self::WrongPrincipal => "wrong_principal",
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SandboxTaskKind {
148 BashForeground,
149 BashBackground,
150 BashPty,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum SpawnPlan {
156 Unsandboxed,
157 Host {
158 shell_path: PathBuf,
159 environment: ChildEnvironment,
160 },
161 Launcher {
162 profile: SandboxProfile,
163 launcher_path: PathBuf,
164 },
165 Refused {
166 code: &'static str,
167 message: String,
168 mismatch_class: Option<&'static str>,
169 },
170}
171
172impl SpawnPlan {
173 pub(crate) fn refusal_code(&self) -> Option<&'static str> {
174 match self {
175 Self::Refused { code, .. } => Some(code),
176 Self::Unsandboxed | Self::Host { .. } | Self::Launcher { .. } => None,
177 }
178 }
179
180 pub(crate) fn refusal_message(&self) -> Option<&str> {
181 match self {
182 Self::Refused { message, .. } => Some(message),
183 Self::Unsandboxed | Self::Host { .. } | Self::Launcher { .. } => None,
184 }
185 }
186
187 pub(crate) fn refusal_mismatch_class(&self) -> Option<&'static str> {
188 match self {
189 Self::Refused { mismatch_class, .. } => *mismatch_class,
190 Self::Unsandboxed | Self::Host { .. } | Self::Launcher { .. } => None,
191 }
192 }
193
194 pub(crate) fn is_native_launcher(&self) -> bool {
195 matches!(self, Self::Launcher { .. })
196 }
197
198 pub(crate) fn host_environment(&self) -> Option<&ChildEnvironment> {
199 match self {
200 Self::Host { environment, .. } => Some(environment),
201 Self::Unsandboxed | Self::Launcher { .. } | Self::Refused { .. } => None,
202 }
203 }
204
205 #[cfg(unix)]
206 pub(crate) fn host_shell_path(&self) -> Option<&Path> {
207 match self {
208 Self::Host { shell_path, .. } => Some(shell_path),
209 Self::Unsandboxed | Self::Launcher { .. } | Self::Refused { .. } => None,
210 }
211 }
212
213 pub(crate) fn temp_dir(&self) -> Option<&Path> {
214 match self {
215 Self::Launcher { profile, .. } => Some(&profile.temp_dir),
216 Self::Unsandboxed | Self::Host { .. } | Self::Refused { .. } => None,
217 }
218 }
219
220 pub(crate) fn cleanup_unspawned(&self) {
221 let Some(temp_dir) = self.temp_dir() else {
222 return;
223 };
224 if is_managed_task_temp_dir(temp_dir) {
225 let _ = std::fs::remove_dir_all(temp_dir);
226 }
227 }
228
229 #[cfg(test)]
230 #[cfg(unix)]
231 pub(crate) fn launcher_for_test(profile: SandboxProfile, launcher_path: PathBuf) -> Self {
232 Self::Launcher {
233 profile,
234 launcher_path,
235 }
236 }
237
238 #[cfg(test)]
239 pub(crate) fn refused_for_test(code: &'static str) -> Self {
240 Self::Refused {
241 code,
242 message: format!("bash process creation refused by sandbox policy: {code}"),
243 mismatch_class: None,
244 }
245 }
246}
247
248#[doc(hidden)]
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct SandboxSpawnObservation {
252 pub principal: AuthenticatedPrincipal,
253 pub requested_tier: RequestedSandboxTier,
254 pub task_kind: SandboxTaskKind,
255}
256
257static TEST_OBSERVATIONS: OnceLock<Mutex<HashMap<PathBuf, Vec<SandboxSpawnObservation>>>> =
258 OnceLock::new();
259
260thread_local! {
261 static CURRENT_PRINCIPAL: RefCell<Option<AuthenticatedPrincipal>> = const { RefCell::new(None) };
262 #[cfg(test)]
263 static TEST_PLAN_OVERRIDE: RefCell<Option<SpawnPlan>> = const { RefCell::new(None) };
264}
265
266struct PrincipalScope(Option<AuthenticatedPrincipal>);
267
268impl Drop for PrincipalScope {
269 fn drop(&mut self) {
270 CURRENT_PRINCIPAL.with(|slot| {
271 slot.replace(self.0.take());
272 });
273 }
274}
275
276pub(crate) fn with_authenticated_principal<R>(
278 principal: AuthenticatedPrincipal,
279 run: impl FnOnce() -> R,
280) -> R {
281 let previous = CURRENT_PRINCIPAL.with(|slot| slot.replace(Some(principal)));
282 let _scope = PrincipalScope(previous);
283 run()
284}
285
286pub(crate) fn current_authenticated_principal() -> AuthenticatedPrincipal {
288 CURRENT_PRINCIPAL
289 .with(|slot| slot.borrow().clone())
290 .unwrap_or(AuthenticatedPrincipal::FirstParty)
291}
292
293pub(crate) fn principal_is_first_party(principal: &AuthenticatedPrincipal) -> bool {
294 matches!(
295 principal,
296 AuthenticatedPrincipal::FirstParty
297 | AuthenticatedPrincipal::RouteBind {
298 trust: PrincipalTrust::FirstParty,
299 ..
300 }
301 )
302}
303
304#[cfg(unix)]
305pub(crate) fn capture_child_environment(overrides: &HashMap<String, String>) -> ChildEnvironment {
306 let mut environment = std::env::vars_os().collect::<ChildEnvironment>();
307 for (key, value) in overrides {
308 environment.insert(OsString::from(key), OsString::from(value));
309 }
310 environment
311}
312
313#[cfg(unix)]
314pub(crate) fn mint_host_escalation_grant(
315 ctx: &AppContext,
316 principal: &AuthenticatedPrincipal,
317 command: &[u8],
318 root: &Path,
319 cwd: &Path,
320 shell_path: &Path,
321 environment: &ChildEnvironment,
322) -> Result<String, String> {
323 mint_host_escalation_grant_at(
324 ctx,
325 principal,
326 command,
327 root,
328 cwd,
329 shell_path,
330 environment,
331 Instant::now(),
332 )
333}
334
335#[cfg(unix)]
336#[allow(clippy::too_many_arguments)]
337fn mint_host_escalation_grant_at(
338 ctx: &AppContext,
339 principal: &AuthenticatedPrincipal,
340 command: &[u8],
341 root: &Path,
342 cwd: &Path,
343 shell_path: &Path,
344 environment: &ChildEnvironment,
345 now: Instant,
346) -> Result<String, String> {
347 let mut store = ctx.escalation_grants().lock();
348 let grant_id = loop {
349 let mut random = [0_u8; 16];
350 getrandom::fill(&mut random)
351 .map_err(|error| format!("failed to mint sandbox escalation grant: {error}"))?;
352 let candidate = format!("esc_{}", hex_bytes(&random));
353 if !store.grants.contains_key(&candidate) {
354 break candidate;
355 }
356 };
357 let grant = EscalationGrant {
358 principal: principal.clone(),
359 root: root.to_path_buf(),
360 digest: escalation_digest(command, root, cwd, principal, shell_path, environment),
361 expires_at: now + ESCALATION_GRANT_TTL,
362 consumed: false,
363 environment: environment.clone(),
364 };
365 store.grants.insert(grant_id.clone(), grant);
366 Ok(grant_id)
367}
368
369#[cfg(unix)]
370fn consume_host_escalation_grant_at(
371 ctx: &AppContext,
372 principal: &AuthenticatedPrincipal,
373 attempt: &HostEscalationAttempt,
374 now: Instant,
375) -> Result<ChildEnvironment, EscalationRefusal> {
376 let mut store = ctx.escalation_grants().lock();
377 let Some(grant) = store.grants.get_mut(&attempt.grant_id) else {
378 return Err(EscalationRefusal::DigestMismatch);
379 };
380 if grant.principal != *principal {
381 return Err(EscalationRefusal::WrongPrincipal);
382 }
383 if grant.root != attempt.root {
384 grant.consumed = true;
385 grant.environment.clear();
386 return Err(EscalationRefusal::DigestMismatch);
387 }
388 if now >= grant.expires_at {
389 grant.environment.clear();
390 return Err(EscalationRefusal::Expired);
391 }
392 if grant.consumed {
393 return Err(EscalationRefusal::Consumed);
394 }
395 let digest = escalation_digest(
396 &attempt.command,
397 &attempt.root,
398 &attempt.cwd,
399 principal,
400 &attempt.shell_path,
401 &attempt.environment,
402 );
403 if digest != grant.digest {
404 grant.consumed = true;
405 grant.environment.clear();
406 return Err(EscalationRefusal::DigestMismatch);
407 }
408 grant.consumed = true;
409 Ok(std::mem::take(&mut grant.environment))
410}
411
412#[cfg(unix)]
413fn escalation_digest(
414 command: &[u8],
415 root: &Path,
416 cwd: &Path,
417 principal: &AuthenticatedPrincipal,
418 shell_path: &Path,
419 environment: &ChildEnvironment,
420) -> blake3::Hash {
421 let mut hasher = blake3::Hasher::new();
422 hash_field(&mut hasher, ESCALATION_DIGEST_TAG);
423 hash_field(&mut hasher, command);
424 hash_field(&mut hasher, &os_bytes(root.as_os_str()));
425 hash_field(&mut hasher, &os_bytes(cwd.as_os_str()));
426 hash_principal(&mut hasher, principal);
427 hash_field(&mut hasher, &os_bytes(shell_path.as_os_str()));
428 hash_field(&mut hasher, env!("CARGO_PKG_VERSION").as_bytes());
429 hash_field(&mut hasher, ESCALATION_TIER);
430 hasher.update(&(environment.len() as u64).to_be_bytes());
431 for (key, value) in environment {
432 hash_field(&mut hasher, &os_bytes(key));
433 hash_field(&mut hasher, &os_bytes(value));
434 }
435 hasher.finalize()
436}
437
438#[cfg(unix)]
439fn hash_principal(hasher: &mut blake3::Hasher, principal: &AuthenticatedPrincipal) {
440 match principal {
441 AuthenticatedPrincipal::FirstParty => hash_field(hasher, b"first_party"),
442 AuthenticatedPrincipal::RouteBind {
443 trust,
444 route_channel,
445 route_epoch,
446 project_root,
447 harness,
448 session_id,
449 principal_id,
450 } => {
451 hash_field(hasher, b"route_bind");
452 hash_field(
453 hasher,
454 match trust {
455 PrincipalTrust::FirstParty => b"first_party",
456 PrincipalTrust::Untrusted => b"untrusted",
457 },
458 );
459 hasher.update(&route_channel.to_be_bytes());
460 hasher.update(&route_epoch.to_be_bytes());
461 hash_field(hasher, &os_bytes(project_root.as_os_str()));
462 hash_field(hasher, harness.as_bytes());
463 hash_field(hasher, session_id.as_bytes());
464 match principal_id {
465 Some(id) => {
466 hasher.update(&[1]);
467 hash_field(hasher, id.as_bytes());
468 }
469 None => {
470 hasher.update(&[0]);
471 }
472 }
473 }
474 }
475}
476
477#[cfg(unix)]
478fn hash_field(hasher: &mut blake3::Hasher, bytes: &[u8]) {
479 hasher.update(&(bytes.len() as u64).to_be_bytes());
480 hasher.update(bytes);
481}
482
483#[cfg(unix)]
484fn os_bytes(value: &OsStr) -> Vec<u8> {
485 use std::os::unix::ffi::OsStrExt;
486 value.as_bytes().to_vec()
487}
488
489#[cfg(unix)]
490fn hex_bytes(bytes: &[u8]) -> String {
491 const HEX: &[u8; 16] = b"0123456789abcdef";
492 let mut out = String::with_capacity(bytes.len() * 2);
493 for byte in bytes {
494 out.push(HEX[(byte >> 4) as usize] as char);
495 out.push(HEX[(byte & 0x0f) as usize] as char);
496 }
497 out
498}
499
500pub(crate) fn native_sandbox_enforced(
506 ctx: &AppContext,
507 principal: &AuthenticatedPrincipal,
508) -> bool {
509 cfg!(unix) && ctx.config().sandbox.enabled && principal_is_first_party(principal)
510}
511
512pub fn resolve_sandbox_spawn(
518 ctx: &AppContext,
519 principal: &AuthenticatedPrincipal,
520 requested_tier: RequestedSandboxTier,
521 task_kind: SandboxTaskKind,
522 task_bundle_dir: &Path,
523 host_escalation: Option<&HostEscalationAttempt>,
524) -> SpawnPlan {
525 note_test_observation(ctx, principal, requested_tier, task_kind);
526
527 #[cfg(test)]
531 if let Some(plan) = TEST_PLAN_OVERRIDE.with(|slot| slot.borrow().clone()) {
532 return plan;
533 }
534
535 if requested_tier == RequestedSandboxTier::Disabled || !ctx.config().sandbox.enabled {
536 return SpawnPlan::Unsandboxed;
537 }
538
539 if requested_tier == RequestedSandboxTier::Host {
540 if !principal_is_first_party(principal) {
541 return SpawnPlan::Refused {
542 code: "sandbox_escalation_denied",
543 message: "sandbox host escalation is unavailable to untrusted principals"
544 .to_string(),
545 mismatch_class: Some("wrong_principal"),
546 };
547 }
548
549 #[cfg(windows)]
550 {
551 warn_windows_unsupported_once();
552 let _ = (ctx, task_kind, task_bundle_dir, host_escalation);
553 return SpawnPlan::Unsandboxed;
554 }
555
556 #[cfg(unix)]
557 {
558 let Some(attempt) = host_escalation else {
559 return escalation_refused(EscalationRefusal::DigestMismatch);
560 };
561 return match consume_host_escalation_grant_at(ctx, principal, attempt, Instant::now()) {
562 Ok(environment) => SpawnPlan::Host {
563 shell_path: attempt.shell_path.clone(),
564 environment,
565 },
566 Err(refusal) => escalation_refused(refusal),
567 };
568 }
569
570 #[cfg(all(not(unix), not(windows)))]
571 {
572 let _ = (ctx, task_kind, task_bundle_dir, host_escalation);
573 return SpawnPlan::Unsandboxed;
574 }
575 }
576
577 if !native_sandbox_enforced(ctx, principal) {
578 #[cfg(windows)]
579 if principal_is_first_party(principal) {
580 warn_windows_unsupported_once();
581 }
582 return SpawnPlan::Unsandboxed;
583 }
584
585 #[cfg(windows)]
586 {
587 let _ = (ctx, task_kind, task_bundle_dir);
588 SpawnPlan::Unsandboxed
589 }
590
591 #[cfg(unix)]
592 {
593 let profile = match build_native_profile(ctx, principal, task_bundle_dir) {
594 Ok(profile) => profile,
595 Err(error) => {
596 return SpawnPlan::Refused {
597 code: "sandbox_unavailable",
598 message: format!(
599 "native sandbox setup failed: {error}; set sandbox.enabled=false to disable native sandboxing"
600 ),
601 mismatch_class: None,
602 };
603 }
604 };
605 let launcher_path = match std::env::current_exe() {
606 Ok(path) => path,
607 Err(error) => {
608 let _ = std::fs::remove_dir_all(&profile.temp_dir);
609 return SpawnPlan::Refused {
610 code: "sandbox_unavailable",
611 message: format!(
612 "native sandbox setup failed to locate the aft executable: {error}; set sandbox.enabled=false to disable native sandboxing"
613 ),
614 mismatch_class: None,
615 };
616 }
617 };
618 crate::slog_info!(
619 "sandbox profile apply: tier=native task_kind={task_kind:?} writable_roots={} read_deny={}",
620 profile.writable_roots.len(),
621 profile.read_deny.len()
622 );
623 crate::slog_debug!(
624 "sandbox profile paths: tier=native writable_roots={:?} write_deny_nested={:?} read_deny={:?} socket_deny={:?} cache_roots={:?} temp_dir={:?}",
625 profile.writable_roots,
626 profile.write_deny_nested,
627 profile.read_deny,
628 profile.socket_deny,
629 profile.cache_roots,
630 profile.temp_dir
631 );
632 SpawnPlan::Launcher {
633 profile,
634 launcher_path,
635 }
636 }
637
638 #[cfg(all(not(unix), not(windows)))]
639 {
640 let _ = (ctx, principal, task_kind, task_bundle_dir);
641 SpawnPlan::Unsandboxed
642 }
643}
644
645#[cfg(unix)]
646fn escalation_refused(refusal: EscalationRefusal) -> SpawnPlan {
647 let class = refusal.class();
648 SpawnPlan::Refused {
649 code: "sandbox_escalation_denied",
650 message: format!("sandbox host escalation grant refused: {class}"),
651 mismatch_class: Some(class),
652 }
653}
654
655#[cfg(windows)]
656fn warn_windows_unsupported_once() {
657 let session = crate::log_ctx::current_session().unwrap_or_else(|| "<unknown-session>".into());
658 if should_warn_windows_unsupported(&session) {
659 crate::slog_warn!("sandbox.enabled is not supported on Windows");
660 }
661}
662
663#[cfg(any(windows, test))]
664fn should_warn_windows_unsupported(session: &str) -> bool {
665 use std::collections::HashSet;
666
667 static WARNED_SESSIONS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
668 WARNED_SESSIONS
669 .get_or_init(|| Mutex::new(HashSet::new()))
670 .lock()
671 .is_ok_and(|mut warned| warned.insert(session.to_string()))
672}
673
674#[cfg(unix)]
675fn build_native_profile(
676 ctx: &AppContext,
677 principal: &AuthenticatedPrincipal,
678 task_bundle_dir: &Path,
679) -> Result<SandboxProfile, String> {
680 let home = std::env::var_os("HOME")
681 .filter(|value| !value.is_empty())
682 .map(PathBuf::from)
683 .ok_or_else(|| {
684 "HOME is not set, so credential and cache paths cannot be resolved".to_string()
685 })?;
686 if !home.is_absolute() {
687 return Err(format!("HOME must be absolute: {}", home.display()));
688 }
689
690 let mut project_roots = Vec::new();
691 if let Some(root) = &ctx.config().project_root {
692 project_roots.push(root.clone());
693 }
694 if let AuthenticatedPrincipal::RouteBind { project_root, .. } = principal {
695 if !project_roots.contains(project_root) {
696 project_roots.push(project_root.clone());
697 }
698 }
699 if project_roots.is_empty() {
700 project_roots.push(
701 std::env::current_dir()
702 .map_err(|error| format!("failed to resolve the current project root: {error}"))?,
703 );
704 }
705
706 for root in &project_roots {
707 if !root.is_dir() {
708 return Err(format!(
709 "project root is not an existing directory: {}",
710 root.display()
711 ));
712 }
713 }
714 if !task_bundle_dir.is_dir() {
715 return Err(format!(
716 "task artifact directory is not an existing directory: {}",
717 task_bundle_dir.display()
718 ));
719 }
720
721 let temp_dir = create_task_temp_dir(task_bundle_dir)?;
722 let result = {
723 let mut writable_roots = project_roots.clone();
724 writable_roots.push(task_bundle_dir.to_path_buf());
725 writable_roots.extend(
726 ctx.config()
727 .sandbox
728 .write_allow
729 .iter()
730 .map(|path| expand_home(path, &home)),
731 );
732
733 let mut write_deny_nested = Vec::new();
734 let mut read_deny = vec![
735 home.join(".ssh"),
736 home.join(".aws"),
737 home.join(".gnupg"),
738 home.join(".config/gcloud"),
739 home.join(".azure"),
740 home.join(".config/cortexkit"),
741 ];
742 for root in &project_roots {
743 write_deny_nested.push(root.join(".git"));
744 write_deny_nested.push(root.join(".cortexkit"));
745 read_deny.push(root.join(".git/hooks"));
746 }
747 read_deny.extend(
748 ctx.config()
749 .sandbox
750 .read_deny
751 .iter()
752 .map(|path| expand_home(path, &home)),
753 );
754
755 let mut cache_roots = vec![
756 home.join(".cargo/registry"),
757 home.join(".cargo/git"),
758 home.join(".rustup/downloads"),
759 home.join(".npm"),
760 home.join(".bun/install/cache"),
761 home.join(".cache/pip"),
762 home.join(".cache/uv"),
763 home.join(".cache/go-build"),
764 home.join(".gradle/caches"),
765 home.join(".m2/repository"),
766 ];
767 #[cfg(target_os = "macos")]
768 cache_roots.extend([
769 home.join("Library/Caches/pip"),
770 home.join("Library/Caches/uv"),
771 home.join("Library/Caches/go-build"),
772 ]);
773 cache_roots.retain(|path| path.is_dir());
774
775 let mut socket_deny = vec![PathBuf::from("/var/run/docker.sock")];
776 if let Some(agent_socket) =
777 std::env::var_os("SSH_AUTH_SOCK").filter(|value| !value.is_empty())
778 {
779 socket_deny.push(PathBuf::from(agent_socket));
780 }
781
782 SandboxProfile::build(
783 writable_roots,
784 write_deny_nested,
785 read_deny,
786 socket_deny,
787 cache_roots,
788 temp_dir.clone(),
789 )
790 .map_err(|error| error.to_string())
791 };
792 if result.is_err() {
793 let _ = std::fs::remove_dir_all(&temp_dir);
794 }
795 result
796}
797
798#[cfg(unix)]
799fn expand_home(path: &Path, home: &Path) -> PathBuf {
800 let mut components = path.components();
801 if components
802 .next()
803 .is_some_and(|component| component.as_os_str() == "~")
804 {
805 return components.fold(home.to_path_buf(), |resolved, component| {
806 resolved.join(component.as_os_str())
807 });
808 }
809 path.to_path_buf()
810}
811
812#[cfg(unix)]
813fn create_task_temp_dir(task_bundle_dir: &Path) -> Result<PathBuf, String> {
814 static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
815 for _ in 0..32 {
816 let nonce = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
817 let path = task_bundle_dir.join(format!(".sandbox-tmp-{}-{nonce}", std::process::id()));
818 match DirBuilder::new().mode(0o700).create(&path) {
819 Ok(()) => {
820 return path.canonicalize().map_err(|error| {
821 format!(
822 "failed to canonicalize task temp directory {}: {error}",
823 path.display()
824 )
825 });
826 }
827 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
828 Err(error) => {
829 return Err(format!(
830 "failed to create task temp directory {}: {error}",
831 path.display()
832 ));
833 }
834 }
835 }
836 Err("failed to allocate a fresh task temp directory after 32 attempts".to_string())
837}
838
839pub(crate) fn is_managed_task_temp_dir(path: &Path) -> bool {
840 path.file_name()
841 .and_then(OsStr::to_str)
842 .is_some_and(|name| name.starts_with(".sandbox-tmp-"))
843}
844
845fn note_test_observation(
846 ctx: &AppContext,
847 principal: &AuthenticatedPrincipal,
848 requested_tier: RequestedSandboxTier,
849 task_kind: SandboxTaskKind,
850) {
851 let Some(observations) = TEST_OBSERVATIONS.get() else {
852 return;
853 };
854 let Some(project_root) = ctx.config().project_root.clone() else {
855 return;
856 };
857 let project_root = observation_key(&project_root);
858 if let Some(project) = observations
859 .lock()
860 .expect("sandbox spawn test observation mutex poisoned")
861 .get_mut(&project_root)
862 {
863 project.push(SandboxSpawnObservation {
864 principal: principal.clone(),
865 requested_tier,
866 task_kind,
867 });
868 }
869}
870
871#[doc(hidden)]
873pub fn install_sandbox_spawn_test_seam(project_root: PathBuf) {
874 TEST_OBSERVATIONS
875 .get_or_init(|| Mutex::new(HashMap::new()))
876 .lock()
877 .expect("sandbox spawn test observation mutex poisoned")
878 .insert(observation_key(&project_root), Vec::new());
879}
880
881#[doc(hidden)]
883pub fn sandbox_spawn_test_observations(project_root: &Path) -> Vec<SandboxSpawnObservation> {
884 TEST_OBSERVATIONS
885 .get()
886 .and_then(|observations| {
887 observations
888 .lock()
889 .expect("sandbox spawn test observation mutex poisoned")
890 .get(&observation_key(project_root))
891 .cloned()
892 })
893 .unwrap_or_default()
894}
895
896#[doc(hidden)]
898pub fn clear_sandbox_spawn_test_seam(project_root: &Path) {
899 if let Some(observations) = TEST_OBSERVATIONS.get() {
900 observations
901 .lock()
902 .expect("sandbox spawn test observation mutex poisoned")
903 .remove(&observation_key(project_root));
904 }
905}
906
907fn observation_key(project_root: &Path) -> PathBuf {
908 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf())
909}
910
911#[cfg(test)]
912pub(crate) fn with_spawn_plan_for_test<R>(plan: SpawnPlan, run: impl FnOnce() -> R) -> R {
913 struct PlanScope(Option<SpawnPlan>);
914
915 impl Drop for PlanScope {
916 fn drop(&mut self) {
917 TEST_PLAN_OVERRIDE.with(|slot| {
918 slot.replace(self.0.take());
919 });
920 }
921 }
922
923 let previous = TEST_PLAN_OVERRIDE.with(|slot| slot.replace(Some(plan)));
924 let _scope = PlanScope(previous);
925 run()
926}
927
928#[cfg(unix)]
933pub(crate) fn detached_command_for_plan(
934 plan: &SpawnPlan,
935 program: &OsStr,
936 args: &[OsString],
937 task_marker: &Path,
938 exit_marker: &Path,
939) -> Result<Command, String> {
940 let (program, args) =
941 command_argv_for_plan(plan, program, args, task_marker, Some(exit_marker))?;
942 let mut command = crate::effective_path::new_command(program);
943 command.args(args);
944 Ok(command)
945}
946
947#[cfg(unix)]
948pub(crate) fn apply_sandbox_environment(plan: &SpawnPlan, command: &mut Command) {
949 if let Some(environment) = plan.host_environment() {
950 command.env_clear().envs(environment);
951 } else if let Some(temp_dir) = plan.temp_dir() {
952 command.env("TMPDIR", temp_dir).env("TEMP", temp_dir);
953 }
954}
955
956pub(crate) fn pty_command_for_plan(
958 plan: &SpawnPlan,
959 program: &OsStr,
960 args: &[OsString],
961 task_marker: &Path,
962 workdir: &Path,
963 env: &HashMap<String, String>,
964) -> Result<CommandBuilder, String> {
965 let (program, args) = command_argv_for_plan(plan, program, args, task_marker, None)?;
966 let mut command = CommandBuilder::new(program);
967 for arg in args {
968 command.arg(arg);
969 }
970 command.cwd(workdir.as_os_str());
971 if let Some(environment) = plan.host_environment() {
972 command.env_clear();
973 for (key, value) in environment {
974 command.env(key, value);
975 }
976 } else {
977 for (key, value) in env {
978 command.env(key, value);
979 }
980 if let Some(temp_dir) = plan.temp_dir() {
981 command.env("TMPDIR", temp_dir);
982 command.env("TEMP", temp_dir);
983 }
984 }
985 Ok(command)
986}
987
988fn command_argv_for_plan(
989 plan: &SpawnPlan,
990 program: &OsStr,
991 args: &[OsString],
992 task_marker: &Path,
993 exit_marker: Option<&Path>,
994) -> Result<(OsString, Vec<OsString>), String> {
995 match plan {
996 SpawnPlan::Unsandboxed | SpawnPlan::Host { .. } => {
997 Ok((program.to_os_string(), args.to_vec()))
998 }
999 SpawnPlan::Refused { code, .. } => Err((*code).to_string()),
1000 SpawnPlan::Launcher {
1001 profile,
1002 launcher_path,
1003 } => launcher_argv(
1004 profile,
1005 launcher_path,
1006 program,
1007 args,
1008 task_marker,
1009 exit_marker,
1010 ),
1011 }
1012}
1013
1014#[cfg(unix)]
1015fn launcher_argv(
1016 profile: &SandboxProfile,
1017 launcher_path: &Path,
1018 program: &OsStr,
1019 args: &[OsString],
1020 task_marker: &Path,
1021 exit_marker: Option<&Path>,
1022) -> Result<(OsString, Vec<OsString>), String> {
1023 let profile_path = write_profile_file(profile, task_marker)?;
1024 let failure_marker = sandbox_failure_marker_path(task_marker)?;
1025 if let Some(exit_marker) = exit_marker {
1026 let mut wrapped = vec![
1027 OsString::from("-c"),
1028 OsString::from(
1029 r#"umask 077
1030launcher=$1
1031profile=$2
1032exit_marker=$3
1033failure_marker=$4
1034shift 4
1035"$launcher" sandbox-launch --profile-file "$profile" --failure-marker "$failure_marker" -- "$@"
1036code=$?
1037if [ "$code" -eq 78 ] && [ ! -e "$exit_marker" ]; then
1038 if [ ! -e "$failure_marker" ]; then
1039 printf "%s" sandbox_unavailable > "$failure_marker.tmp.$$" && mv -f "$failure_marker.tmp.$$" "$failure_marker"
1040 fi
1041 printf "%s" "$code" > "$exit_marker.tmp.$$" && mv -f "$exit_marker.tmp.$$" "$exit_marker"
1042fi
1043exit "$code""#,
1044 ),
1045 OsString::from("aft-sandbox-supervisor"),
1046 launcher_path.as_os_str().to_os_string(),
1047 profile_path.into_os_string(),
1048 exit_marker.as_os_str().to_os_string(),
1049 failure_marker.into_os_string(),
1050 program.to_os_string(),
1051 ];
1052 wrapped.extend_from_slice(args);
1053 return Ok((OsString::from("/bin/sh"), wrapped));
1054 }
1055
1056 let mut wrapped = vec![
1057 OsString::from("sandbox-launch"),
1058 OsString::from("--profile-file"),
1059 profile_path.into_os_string(),
1060 OsString::from("--failure-marker"),
1061 failure_marker.into_os_string(),
1062 OsString::from("--"),
1063 program.to_os_string(),
1064 ];
1065 wrapped.extend_from_slice(args);
1066 Ok((launcher_path.as_os_str().to_os_string(), wrapped))
1067}
1068
1069#[cfg(not(unix))]
1070fn launcher_argv(
1071 _profile: &SandboxProfile,
1072 _launcher_path: &Path,
1073 _program: &OsStr,
1074 _args: &[OsString],
1075 _task_marker: &Path,
1076 _exit_marker: Option<&Path>,
1077) -> Result<(OsString, Vec<OsString>), String> {
1078 Err("sandbox_unavailable".to_string())
1079}
1080
1081#[cfg(unix)]
1082fn sandbox_failure_marker_path(task_marker: &Path) -> Result<PathBuf, String> {
1083 let parent = task_marker
1084 .parent()
1085 .ok_or_else(|| "sandbox task marker has no parent directory".to_string())?;
1086 let stem = task_marker
1087 .file_stem()
1088 .and_then(|stem| stem.to_str())
1089 .unwrap_or("bash-task");
1090 Ok(parent.join(format!("{stem}.sandbox-unavailable")))
1091}
1092
1093#[cfg(unix)]
1094fn write_profile_file(profile: &SandboxProfile, task_marker: &Path) -> Result<PathBuf, String> {
1095 let parent = task_marker
1096 .parent()
1097 .ok_or_else(|| "sandbox task marker has no parent directory".to_string())?;
1098 let stem = task_marker
1099 .file_stem()
1100 .and_then(|stem| stem.to_str())
1101 .unwrap_or("bash-task");
1102 let path = parent.join(format!("{stem}.sandbox-profile.json"));
1103 let bytes = serde_json::to_vec(profile)
1104 .map_err(|error| format!("failed to serialize sandbox profile: {error}"))?;
1105 let mut file = OpenOptions::new()
1106 .write(true)
1107 .create_new(true)
1108 .mode(0o600)
1109 .open(&path)
1110 .map_err(|error| format!("failed to create sandbox profile file: {error}"))?;
1111 file.write_all(&bytes)
1112 .map_err(|error| format!("failed to write sandbox profile file: {error}"))?;
1113 file.sync_all()
1114 .map_err(|error| format!("failed to sync sandbox profile file: {error}"))?;
1115 std::fs::canonicalize(&path)
1116 .map_err(|error| format!("failed to canonicalize sandbox profile file: {error}"))
1117}
1118
1119#[cfg(all(test, unix))]
1120mod tests {
1121 use std::os::unix::fs::PermissionsExt;
1122
1123 use super::*;
1124
1125 #[test]
1126 fn host_plan_clears_inherited_environment_and_applies_snapshot() {
1127 let environment =
1128 ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
1129 let plan = SpawnPlan::Host {
1130 shell_path: PathBuf::from("/bin/sh"),
1131 environment,
1132 };
1133 let mut command = Command::new("/bin/sh");
1134 command
1135 .arg("-c")
1136 .arg("test -z \"$SHOULD_DISAPPEAR\" && printf %s \"$APPROVED\"")
1137 .env("SHOULD_DISAPPEAR", "yes");
1138 apply_sandbox_environment(&plan, &mut command);
1139 let output = command.output().unwrap();
1140 assert!(output.status.success());
1141 assert_eq!(output.stdout, b"snapshot");
1142 }
1143
1144 #[test]
1145 fn product_profile_file_is_private() {
1146 let root = tempfile::tempdir().unwrap();
1147 let task_dir = root.path().join("task");
1148 let project = root.path().join("project");
1149 let temp = root.path().join("temp");
1150 std::fs::create_dir_all(&task_dir).unwrap();
1151 std::fs::create_dir_all(&project).unwrap();
1152 std::fs::create_dir_all(&temp).unwrap();
1153 let profile = SandboxProfile::build(
1154 vec![project],
1155 Vec::new(),
1156 Vec::new(),
1157 Vec::new(),
1158 Vec::new(),
1159 temp,
1160 )
1161 .unwrap();
1162 let marker = task_dir.join("bash-private.json");
1163
1164 let path = write_profile_file(&profile, &marker).unwrap();
1165 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1166 assert_eq!(mode, 0o600);
1167 }
1168}
1169
1170#[cfg(test)]
1171mod policy_tests {
1172 use super::*;
1173
1174 fn context(project_root: PathBuf) -> AppContext {
1175 AppContext::new(
1176 Box::new(crate::parser::TreeSitterProvider::new()),
1177 crate::config::Config {
1178 project_root: Some(project_root),
1179 sandbox: crate::config::SandboxConfig {
1180 enabled: true,
1181 ..crate::config::SandboxConfig::default()
1182 },
1183 ..crate::config::Config::default()
1184 },
1185 )
1186 }
1187
1188 #[cfg(unix)]
1189 #[test]
1190 fn native_sandbox_predicate_controls_spawn_and_rewrite() {
1191 let project = tempfile::tempdir().unwrap();
1192 let file = project.path().join("rewrite-probe.txt");
1193 std::fs::write(&file, "sandboxed\n").unwrap();
1194 let ctx = context(project.path().to_path_buf());
1195 ctx.update_config(|config| config.experimental_bash_rewrite = true);
1196 let principal = AuthenticatedPrincipal::FirstParty;
1197 let command = format!("cat {}", file.display());
1198
1199 assert!(native_sandbox_enforced(&ctx, &principal));
1200 assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_none());
1201 let sandboxed = resolve_sandbox_spawn(
1202 &ctx,
1203 &principal,
1204 RequestedSandboxTier::Native,
1205 SandboxTaskKind::BashForeground,
1206 project.path(),
1207 None,
1208 );
1209 assert!(matches!(&sandboxed, SpawnPlan::Launcher { .. }));
1210 sandboxed.cleanup_unspawned();
1211
1212 ctx.update_config(|config| config.sandbox.enabled = false);
1213 assert!(!native_sandbox_enforced(&ctx, &principal));
1214 assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_some());
1215 assert_eq!(
1216 resolve_sandbox_spawn(
1217 &ctx,
1218 &principal,
1219 RequestedSandboxTier::Native,
1220 SandboxTaskKind::BashForeground,
1221 project.path(),
1222 None,
1223 ),
1224 SpawnPlan::Unsandboxed
1225 );
1226 }
1227
1228 #[test]
1229 fn untrusted_principal_never_enters_the_native_launcher() {
1230 let project = tempfile::tempdir().unwrap();
1231 let ctx = context(project.path().to_path_buf());
1232 let principal = AuthenticatedPrincipal::RouteBind {
1233 trust: PrincipalTrust::Untrusted,
1234 route_channel: 7,
1235 route_epoch: 1,
1236 project_root: project.path().to_path_buf(),
1237 harness: "mcp:test".to_string(),
1238 session_id: "untrusted-sandbox-test".to_string(),
1239 principal_id: Some("unverified".to_string()),
1240 };
1241
1242 let plan = resolve_sandbox_spawn(
1243 &ctx,
1244 &principal,
1245 RequestedSandboxTier::Native,
1246 SandboxTaskKind::BashForeground,
1247 project.path(),
1248 None,
1249 );
1250 assert_eq!(plan, SpawnPlan::Unsandboxed);
1251 }
1252
1253 #[cfg(unix)]
1254 fn grant_attempt(
1255 grant_id: String,
1256 project: &Path,
1257 command: &[u8],
1258 environment: ChildEnvironment,
1259 ) -> HostEscalationAttempt {
1260 HostEscalationAttempt {
1261 grant_id,
1262 command: command.to_vec(),
1263 root: project.to_path_buf(),
1264 cwd: project.to_path_buf(),
1265 shell_path: PathBuf::from("/bin/sh"),
1266 environment,
1267 }
1268 }
1269
1270 #[cfg(unix)]
1271 fn mint_test_grant(
1272 ctx: &AppContext,
1273 principal: &AuthenticatedPrincipal,
1274 project: &Path,
1275 command: &[u8],
1276 environment: &ChildEnvironment,
1277 now: Instant,
1278 ) -> String {
1279 mint_host_escalation_grant_at(
1280 ctx,
1281 principal,
1282 command,
1283 project,
1284 project,
1285 Path::new("/bin/sh"),
1286 environment,
1287 now,
1288 )
1289 .unwrap()
1290 }
1291
1292 #[cfg(unix)]
1293 fn refusal_class(plan: &SpawnPlan) -> Option<&'static str> {
1294 plan.refusal_mismatch_class()
1295 }
1296
1297 #[cfg(unix)]
1298 #[test]
1299 fn escalation_grant_binds_exact_command_and_environment() {
1300 let project = tempfile::tempdir().unwrap();
1301 let ctx = context(project.path().to_path_buf());
1302 let principal = AuthenticatedPrincipal::FirstParty;
1303 let environment = ChildEnvironment::from([
1304 (OsString::from("A"), OsString::from("one")),
1305 (OsString::from("B"), OsString::from("two")),
1306 ]);
1307
1308 for (command, retry_environment) in [
1309 (b"printf approved!".as_slice(), environment.clone()),
1310 (
1311 b"printf approved".as_slice(),
1312 ChildEnvironment::from([
1313 (OsString::from("A"), OsString::from("changed")),
1314 (OsString::from("B"), OsString::from("two")),
1315 ]),
1316 ),
1317 ] {
1318 let grant_id = mint_test_grant(
1319 &ctx,
1320 &principal,
1321 project.path(),
1322 b"printf approved",
1323 &environment,
1324 Instant::now(),
1325 );
1326 let attempt = grant_attempt(grant_id, project.path(), command, retry_environment);
1327 let plan = resolve_sandbox_spawn(
1328 &ctx,
1329 &principal,
1330 RequestedSandboxTier::Host,
1331 SandboxTaskKind::BashForeground,
1332 project.path(),
1333 Some(&attempt),
1334 );
1335 assert_eq!(refusal_class(&plan), Some("digest_mismatch"));
1336 }
1337 }
1338
1339 #[cfg(unix)]
1340 #[test]
1341 fn escalation_grant_is_single_use_and_expires() {
1342 let project = tempfile::tempdir().unwrap();
1343 let ctx = context(project.path().to_path_buf());
1344 let principal = AuthenticatedPrincipal::FirstParty;
1345 let environment =
1346 ChildEnvironment::from([(OsString::from("ONLY"), OsString::from("snapshot"))]);
1347 let grant_id = mint_test_grant(
1348 &ctx,
1349 &principal,
1350 project.path(),
1351 b"true",
1352 &environment,
1353 Instant::now(),
1354 );
1355 let attempt = grant_attempt(grant_id, project.path(), b"true", environment.clone());
1356 let first = resolve_sandbox_spawn(
1357 &ctx,
1358 &principal,
1359 RequestedSandboxTier::Host,
1360 SandboxTaskKind::BashForeground,
1361 project.path(),
1362 Some(&attempt),
1363 );
1364 assert!(matches!(first, SpawnPlan::Host { .. }));
1365 let second = resolve_sandbox_spawn(
1366 &ctx,
1367 &principal,
1368 RequestedSandboxTier::Host,
1369 SandboxTaskKind::BashForeground,
1370 project.path(),
1371 Some(&attempt),
1372 );
1373 assert_eq!(refusal_class(&second), Some("consumed"));
1374
1375 let expired_id = mint_test_grant(
1376 &ctx,
1377 &principal,
1378 project.path(),
1379 b"true",
1380 &environment,
1381 Instant::now() - ESCALATION_GRANT_TTL - Duration::from_millis(1),
1382 );
1383 let expired_attempt =
1384 grant_attempt(expired_id, project.path(), b"true", environment.clone());
1385 let expired = resolve_sandbox_spawn(
1386 &ctx,
1387 &principal,
1388 RequestedSandboxTier::Host,
1389 SandboxTaskKind::BashForeground,
1390 project.path(),
1391 Some(&expired_attempt),
1392 );
1393 assert_eq!(refusal_class(&expired), Some("expired"));
1394 }
1395
1396 #[cfg(unix)]
1397 #[test]
1398 fn escalation_grant_binds_principal_and_happy_path_reuses_snapshot() {
1399 let project = tempfile::tempdir().unwrap();
1400 let ctx = context(project.path().to_path_buf());
1401 let principal = AuthenticatedPrincipal::RouteBind {
1402 trust: PrincipalTrust::FirstParty,
1403 route_channel: 9,
1404 route_epoch: 2,
1405 project_root: project.path().to_path_buf(),
1406 harness: "opencode".to_string(),
1407 session_id: "session-x".to_string(),
1408 principal_id: Some("direct".to_string()),
1409 };
1410 let environment =
1411 ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
1412 let wrong_id = mint_test_grant(
1413 &ctx,
1414 &principal,
1415 project.path(),
1416 b"true",
1417 &environment,
1418 Instant::now(),
1419 );
1420 let wrong_attempt = grant_attempt(wrong_id, project.path(), b"true", environment.clone());
1421 let wrong = resolve_sandbox_spawn(
1422 &ctx,
1423 &AuthenticatedPrincipal::FirstParty,
1424 RequestedSandboxTier::Host,
1425 SandboxTaskKind::BashForeground,
1426 project.path(),
1427 Some(&wrong_attempt),
1428 );
1429 assert_eq!(refusal_class(&wrong), Some("wrong_principal"));
1430
1431 let happy_id = mint_test_grant(
1432 &ctx,
1433 &principal,
1434 project.path(),
1435 b"true",
1436 &environment,
1437 Instant::now(),
1438 );
1439 let happy_attempt = grant_attempt(happy_id, project.path(), b"true", environment.clone());
1440 let happy = resolve_sandbox_spawn(
1441 &ctx,
1442 &principal,
1443 RequestedSandboxTier::Host,
1444 SandboxTaskKind::BashForeground,
1445 project.path(),
1446 Some(&happy_attempt),
1447 );
1448 match happy {
1449 SpawnPlan::Host {
1450 shell_path,
1451 environment: actual,
1452 } => {
1453 assert_eq!(shell_path, Path::new("/bin/sh"));
1454 assert_eq!(actual, environment);
1455 }
1456 other => panic!("expected host plan, got {other:?}"),
1457 }
1458 }
1459
1460 #[cfg(unix)]
1461 #[test]
1462 fn untrusted_host_request_refuses_without_minting_a_grant() {
1463 let project = tempfile::tempdir().unwrap();
1464 let ctx = context(project.path().to_path_buf());
1465 let principal = AuthenticatedPrincipal::RouteBind {
1466 trust: PrincipalTrust::Untrusted,
1467 route_channel: 7,
1468 route_epoch: 1,
1469 project_root: project.path().to_path_buf(),
1470 harness: "mcp:test".to_string(),
1471 session_id: "untrusted-escalation-test".to_string(),
1472 principal_id: Some("unverified".to_string()),
1473 };
1474 let plan = resolve_sandbox_spawn(
1475 &ctx,
1476 &principal,
1477 RequestedSandboxTier::Host,
1478 SandboxTaskKind::BashForeground,
1479 project.path(),
1480 None,
1481 );
1482 assert_eq!(plan.refusal_code(), Some("sandbox_escalation_denied"));
1483 assert!(ctx.escalation_grants().lock().grants.is_empty());
1484 }
1485
1486 #[test]
1487 fn windows_unsupported_warning_is_once_per_session() {
1488 let suffix = format!("{}-{:?}", std::process::id(), std::thread::current().id());
1489 let first = format!("windows-warning-first-{suffix}");
1490 let second = format!("windows-warning-second-{suffix}");
1491 assert!(should_warn_windows_unsupported(&first));
1492 assert!(!should_warn_windows_unsupported(&first));
1493 assert!(should_warn_windows_unsupported(&second));
1494 }
1495
1496 #[cfg(windows)]
1497 #[test]
1498 fn enabled_host_request_is_unsandboxed_on_windows_without_a_grant() {
1499 let project = tempfile::tempdir().unwrap();
1500 let ctx = context(project.path().to_path_buf());
1501 let plan = resolve_sandbox_spawn(
1502 &ctx,
1503 &AuthenticatedPrincipal::FirstParty,
1504 RequestedSandboxTier::Host,
1505 SandboxTaskKind::BashForeground,
1506 project.path(),
1507 None,
1508 );
1509 assert_eq!(plan, SpawnPlan::Unsandboxed);
1510 }
1511
1512 #[cfg(windows)]
1513 #[test]
1514 fn enabled_native_tier_is_unsandboxed_on_windows() {
1515 let project = tempfile::tempdir().unwrap();
1516 let ctx = context(project.path().to_path_buf());
1517 let plan = resolve_sandbox_spawn(
1518 &ctx,
1519 &AuthenticatedPrincipal::FirstParty,
1520 RequestedSandboxTier::Native,
1521 SandboxTaskKind::BashForeground,
1522 project.path(),
1523 None,
1524 );
1525 assert_eq!(plan, SpawnPlan::Unsandboxed);
1526 }
1527}