Skip to main content

aft/
sandbox_spawn.rs

1//! Policy and process wiring for agent-provided shell commands.
2//!
3//! Every agent bash process reaches [`resolve_sandbox_spawn`] and carries the
4//! resulting [`SpawnPlan`] into one of the two process-creation primitives:
5//! detached pipes or PTY. Foreground orchestration uses the detached registry
6//! too, so it does not create a third process-creation path.
7//!
8//! AFT also starts processes for its own implementation. Those are outside this
9//! seam because they do not execute an agent command: external formatters,
10//! linters, and type checkers in `format`; LSP servers and Windows LSP cleanup in
11//! `lsp::client` and `lsp::child_registry`; git probes in `search_index`,
12//! `readonly_artifacts`, `commands::configure`, and `commands::conflicts`; login
13//! shell PATH discovery in `effective_path`; and Windows process liveness or
14//! termination helpers in `artifact_owner`, `fs_lock`, and
15//! `bash_background::process`. Image and PDF handling in `commands::read` is
16//! in-process and creates no child. Keeping this inventory here makes the
17//! agent-command boundary explicit without accidentally applying agent policy to
18//! AFT's internal tooling.
19
20use 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/// Server-authenticated trust classification for a route bind.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PrincipalTrust {
48    FirstParty,
49    Untrusted,
50}
51
52/// Principal data supplied by the server-side transport, never by a bash body.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum AuthenticatedPrincipal {
55    /// Standalone NDJSON and first-party plugin bindings have no route identity.
56    FirstParty,
57    /// Identity captured from an authenticated subc route bind.
58    RouteBind {
59        trust: PrincipalTrust,
60        route_channel: u16,
61        route_epoch: u32,
62        project_root: PathBuf,
63        harness: String,
64        session_id: String,
65        /// Server principal label (`direct`, `reserved:<module>`, or
66        /// `unverified`). `None` preserves an absent principal for future
67        /// fail-closed policy instead of silently treating it as first-party.
68        principal_id: Option<String>,
69    },
70}
71
72/// Sandbox tier requested by the caller.
73#[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/// Agent-command path that is about to create a process.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SandboxTaskKind {
148    BashForeground,
149    BashBackground,
150    BashPty,
151}
152
153/// Complete process-launch decision consumed by a spawn primitive.
154#[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/// One resolver invocation captured by the project-keyed test seam.
249#[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
276/// Run dispatch with server-owned principal data installed for bash resolution.
277pub(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
286/// Current dispatch principal. Standalone requests are first-party by construction.
287pub(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
500/// Returns true on Unix when native sandboxing is enabled for a first-party caller.
501///
502/// Process spawning and in-process bash rewriting both use this check so the
503/// command executes through the configured sandbox instead of being rewritten
504/// to run inside the unsandboxed AFT process.
505pub(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
512/// Resolve policy for an agent-command process.
513///
514/// `task_bundle_dir` must be the already-created directory that owns the task's
515/// capture files. The native builder creates a fresh private temp directory
516/// beneath it and includes both directories in the profile.
517pub 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    // This check must remain ahead of every platform and production-policy
528    // branch. Windows tests use it to avoid entering process paths that cannot
529    // consume native launcher plans.
530    #[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/// Start recording resolver calls for one project root.
872#[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/// Snapshot resolver calls recorded for one project root.
882#[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/// Remove one project-root resolver test seam.
897#[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/// Build a detached `Command` while enforcing the required launch plan.
929///
930/// Windows detached spawns route through the shell-candidate ladder, which
931/// enforces the plan inline, so this helper is Unix-only.
932#[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
956/// Build a PTY `CommandBuilder` while enforcing the required launch plan.
957pub(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}