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;
21#[cfg(any(test, target_os = "linux"))]
22use std::collections::BTreeSet;
23use std::collections::{BTreeMap, HashMap};
24#[cfg(target_os = "linux")]
25use std::ffi::{CStr, CString};
26use std::ffi::{OsStr, OsString};
27#[cfg(unix)]
28use std::fs::DirBuilder;
29use std::fs::File;
30#[cfg(unix)]
31use std::io::{Read, Seek, SeekFrom};
32#[cfg(unix)]
33use std::os::fd::RawFd;
34#[cfg(target_os = "linux")]
35use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
36#[cfg(target_os = "linux")]
37use std::os::unix::ffi::OsStrExt;
38#[cfg(not(unix))]
39type RawFd = i32;
40#[cfg(unix)]
41use std::os::unix::fs::DirBuilderExt;
42use std::path::{Path, PathBuf};
43#[cfg(unix)]
44use std::process::Command;
45#[cfg(unix)]
46use std::sync::atomic::{AtomicU64, Ordering};
47#[cfg(unix)]
48use std::sync::Arc;
49use std::sync::{Mutex, OnceLock};
50#[cfg(unix)]
51use std::time::{Duration, Instant};
52
53use portable_pty::CommandBuilder;
54
55use crate::context::AppContext;
56use crate::sandbox_profile::SandboxProfile;
57
58pub const SANDBOX_UNAVAILABLE_EXIT_CODE: i32 = 78;
59
60/// Server-authenticated trust classification for a route bind.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum PrincipalTrust {
63    FirstParty,
64    Untrusted,
65}
66
67/// Principal data supplied by the server-side transport, never by a bash body.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum AuthenticatedPrincipal {
70    /// Standalone NDJSON and first-party plugin bindings have no route identity.
71    FirstParty,
72    /// Identity captured from an authenticated subc route bind.
73    RouteBind {
74        trust: PrincipalTrust,
75        route_channel: u16,
76        route_epoch: u32,
77        project_root: PathBuf,
78        harness: String,
79        session_id: String,
80        /// Server principal label (`direct`, `reserved:<module>`, or
81        /// `unverified`). `None` preserves an absent principal for future
82        /// fail-closed policy instead of silently treating it as first-party.
83        principal_id: Option<String>,
84    },
85}
86
87/// Sandbox tier requested by the caller.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum RequestedSandboxTier {
90    Disabled,
91    Native,
92    Host,
93}
94
95pub(crate) type ChildEnvironment = BTreeMap<OsString, OsString>;
96
97#[cfg(unix)]
98const ESCALATION_GRANT_TTL: Duration = Duration::from_secs(120);
99#[cfg(unix)]
100const ESCALATION_DIGEST_TAG: &[u8] = b"aft-escalation-payload-v3";
101#[cfg(unix)]
102const PAYLOAD_WRAPPER: &[u8] = br#"#!/bin/sh
103shell=$1
104command=$2
105exit_fd=$3
106"$shell" -c "$command"
107code=$?
108printf "%s" "$code" >&"$exit_fd"
109exit "$code"
110"#;
111#[cfg(unix)]
112const ENVIRONMENT_TAG: &[u8] = b"AFTENV1\0";
113#[cfg(unix)]
114const ESCALATION_TIER: &[u8] = b"host";
115
116#[cfg(unix)]
117#[derive(Debug, Clone)]
118struct EscalationGrant {
119    principal: AuthenticatedPrincipal,
120    root: PathBuf,
121    digest: blake3::Hash,
122    expires_at: Instant,
123    consumed: bool,
124    session_dir: PathBuf,
125    task_id: String,
126}
127
128#[cfg(unix)]
129#[derive(Debug, Default)]
130pub(crate) struct EscalationGrantStore {
131    grants: HashMap<String, EscalationGrant>,
132}
133
134#[cfg(unix)]
135impl EscalationGrantStore {
136    #[cfg(test)]
137    pub(crate) fn len_for_test(&self) -> usize {
138        self.grants.len()
139    }
140}
141
142#[derive(Debug, Clone)]
143pub struct HostEscalationAttempt {
144    pub grant_id: String,
145    pub command: Vec<u8>,
146    pub root: PathBuf,
147    pub cwd: PathBuf,
148    pub shell_path: PathBuf,
149    pub environment: ChildEnvironment,
150}
151
152#[cfg(unix)]
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub(crate) enum EscalationRefusal {
155    Expired,
156    Consumed,
157    DigestMismatch,
158    WrongPrincipal,
159}
160
161#[cfg(unix)]
162impl EscalationRefusal {
163    pub(crate) fn class(self) -> &'static str {
164        match self {
165            Self::Expired => "expired",
166            Self::Consumed => "consumed",
167            Self::DigestMismatch => "digest_mismatch",
168            Self::WrongPrincipal => "wrong_principal",
169        }
170    }
171}
172
173/// Agent-command path that is about to create a process.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum SandboxTaskKind {
176    BashForeground,
177    BashBackground,
178    BashPty,
179}
180
181#[cfg(unix)]
182struct PreparedTaskInner {
183    paths: crate::bash_background::persistence::TaskPaths,
184    dirs: crate::bash_background::persistence::TaskDirs,
185    digest: blake3::Hash,
186    environment: ChildEnvironment,
187    command_bytes: Arc<Vec<u8>>,
188    wrapper_bytes: Arc<Vec<u8>>,
189    _command_file: Arc<File>,
190    _wrapper_file: Arc<File>,
191    _environment_file: Arc<File>,
192}
193
194/// A materialized payload whose bytes have already been validated through held handles.
195#[cfg(unix)]
196#[derive(Clone)]
197#[doc(hidden)]
198pub struct PreparedTask(Arc<PreparedTaskInner>);
199
200#[cfg(unix)]
201impl std::fmt::Debug for PreparedTask {
202    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        formatter
204            .debug_struct("PreparedTask")
205            .field("task_id", &self.0.paths.task_id)
206            .field("digest", &self.0.digest.to_hex().as_str())
207            .finish_non_exhaustive()
208    }
209}
210
211#[cfg(unix)]
212impl PartialEq for PreparedTask {
213    fn eq(&self, other: &Self) -> bool {
214        self.0.paths.task_id == other.0.paths.task_id
215            && self.0.paths.session_dir == other.0.paths.session_dir
216            && self.0.digest == other.0.digest
217    }
218}
219
220#[cfg(unix)]
221impl Eq for PreparedTask {}
222
223#[cfg(unix)]
224pub(crate) struct PayloadInvocation {
225    pub(crate) wrapper_text: OsString,
226    pub(crate) command_text: OsString,
227}
228
229#[cfg(unix)]
230impl PreparedTask {
231    #[cfg(test)]
232    pub(crate) fn paths(&self) -> &crate::bash_background::persistence::TaskPaths {
233        &self.0.paths
234    }
235
236    pub(crate) fn resolved_task(&self) -> crate::bash_background::persistence::ResolvedTask {
237        crate::bash_background::persistence::ResolvedTask {
238            paths: self.0.paths.clone(),
239            dirs: self.0.dirs.clone(),
240        }
241    }
242
243    pub(crate) fn environment(&self) -> &ChildEnvironment {
244        &self.0.environment
245    }
246
247    pub(crate) fn command_text(&self) -> Result<&str, String> {
248        std::str::from_utf8(&self.0.command_bytes)
249            .map_err(|error| format!("verified bash command is not UTF-8: {error}"))
250    }
251
252    pub(crate) fn payload_read_grants(&self) -> Vec<PathBuf> {
253        control_payload_read_grants(&self.0.paths.io_dir)
254            .expect("prepared task paths already passed strict validation")
255    }
256
257    pub(crate) fn invocation(&self) -> Result<PayloadInvocation, String> {
258        let wrapper = std::str::from_utf8(&self.0.wrapper_bytes)
259            .map_err(|error| format!("verified wrapper payload is not UTF-8: {error}"))?;
260        Ok(PayloadInvocation {
261            wrapper_text: OsString::from(wrapper),
262            command_text: OsString::from(self.command_text()?),
263        })
264    }
265}
266
267/// Complete process-launch decision consumed by a spawn primitive.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum SpawnPlan {
270    Unsandboxed,
271    Host {
272        shell_path: PathBuf,
273        environment: ChildEnvironment,
274    },
275    Launcher {
276        profile: SandboxProfile,
277        launcher_path: PathBuf,
278    },
279    #[cfg(unix)]
280    Prepared {
281        plan: Box<SpawnPlan>,
282        task: PreparedTask,
283    },
284    Refused {
285        code: &'static str,
286        message: String,
287        mismatch_class: Option<&'static str>,
288    },
289}
290
291impl SpawnPlan {
292    fn policy(&self) -> &Self {
293        #[cfg(unix)]
294        if let Self::Prepared { plan, .. } = self {
295            return plan.policy();
296        }
297        self
298    }
299
300    #[cfg(unix)]
301    pub(crate) fn with_prepared_task(self, task: PreparedTask) -> Self {
302        if matches!(self, Self::Refused { .. }) {
303            return self;
304        }
305
306        #[cfg(target_os = "linux")]
307        let mut plan = self;
308        #[cfg(not(target_os = "linux"))]
309        let plan = self;
310
311        #[cfg(target_os = "linux")]
312        if let Self::Launcher { profile, .. } = &mut plan {
313            let payload_grants = task.payload_read_grants();
314            if let Err(error) = add_linux_payload_read_grants(profile, &payload_grants) {
315                return Self::Refused {
316                    code: "sandbox_unavailable",
317                    message: format!(
318                        "native sandbox payload read grants violate the read floor: {error}"
319                    ),
320                    mismatch_class: None,
321                };
322            }
323        }
324
325        Self::Prepared {
326            plan: Box::new(plan),
327            task,
328        }
329    }
330
331    #[cfg(unix)]
332    pub(crate) fn prepared_task(&self) -> Option<&PreparedTask> {
333        match self {
334            Self::Prepared { task, .. } => Some(task),
335            _ => None,
336        }
337    }
338
339    pub fn payload_read_grants(&self) -> Vec<PathBuf> {
340        #[cfg(unix)]
341        if let Some(task) = self.prepared_task() {
342            return task.payload_read_grants();
343        }
344        Vec::new()
345    }
346
347    pub(crate) fn refusal_code(&self) -> Option<&'static str> {
348        match self.policy() {
349            Self::Refused { code, .. } => Some(code),
350            _ => None,
351        }
352    }
353
354    pub(crate) fn refusal_message(&self) -> Option<&str> {
355        match self.policy() {
356            Self::Refused { message, .. } => Some(message),
357            _ => None,
358        }
359    }
360
361    pub(crate) fn refusal_mismatch_class(&self) -> Option<&'static str> {
362        match self.policy() {
363            Self::Refused { mismatch_class, .. } => *mismatch_class,
364            _ => None,
365        }
366    }
367
368    pub(crate) fn is_native_launcher(&self) -> bool {
369        matches!(self.policy(), Self::Launcher { .. })
370    }
371
372    #[cfg(unix)]
373    pub(crate) fn host_shell_path(&self) -> Option<&Path> {
374        match self.policy() {
375            Self::Host { shell_path, .. } => Some(shell_path),
376            _ => None,
377        }
378    }
379
380    pub(crate) fn temp_dir(&self) -> Option<&Path> {
381        match self.policy() {
382            Self::Launcher { profile, .. } => Some(&profile.temp_dir),
383            _ => None,
384        }
385    }
386
387    pub(crate) fn cleanup_unspawned(&self) {
388        let Some(temp_dir) = self.temp_dir() else {
389            return;
390        };
391        if is_managed_task_temp_dir(temp_dir) {
392            let _ = std::fs::remove_dir_all(temp_dir);
393        }
394    }
395
396    #[cfg(test)]
397    #[cfg(unix)]
398    pub(crate) fn launcher_for_test(profile: SandboxProfile, launcher_path: PathBuf) -> Self {
399        Self::Launcher {
400            profile,
401            launcher_path,
402        }
403    }
404
405    #[cfg(test)]
406    pub(crate) fn refused_for_test(code: &'static str) -> Self {
407        Self::Refused {
408            code,
409            message: format!("bash process creation refused by sandbox policy: {code}"),
410            mismatch_class: None,
411        }
412    }
413}
414
415/// One resolver invocation captured by the project-keyed test seam.
416#[doc(hidden)]
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct SandboxSpawnObservation {
419    pub principal: AuthenticatedPrincipal,
420    pub requested_tier: RequestedSandboxTier,
421    pub task_kind: SandboxTaskKind,
422}
423
424static TEST_OBSERVATIONS: OnceLock<Mutex<HashMap<PathBuf, Vec<SandboxSpawnObservation>>>> =
425    OnceLock::new();
426
427thread_local! {
428    static CURRENT_PRINCIPAL: RefCell<Option<AuthenticatedPrincipal>> = const { RefCell::new(None) };
429    #[cfg(test)]
430    static TEST_PLAN_OVERRIDE: RefCell<Option<SpawnPlan>> = const { RefCell::new(None) };
431}
432
433struct PrincipalScope(Option<AuthenticatedPrincipal>);
434
435impl Drop for PrincipalScope {
436    fn drop(&mut self) {
437        CURRENT_PRINCIPAL.with(|slot| {
438            slot.replace(self.0.take());
439        });
440    }
441}
442
443/// Run dispatch with server-owned principal data installed for bash resolution.
444pub(crate) fn with_authenticated_principal<R>(
445    principal: AuthenticatedPrincipal,
446    run: impl FnOnce() -> R,
447) -> R {
448    let previous = CURRENT_PRINCIPAL.with(|slot| slot.replace(Some(principal)));
449    let _scope = PrincipalScope(previous);
450    run()
451}
452
453/// Current dispatch principal. Standalone requests are first-party by construction.
454pub(crate) fn current_authenticated_principal() -> AuthenticatedPrincipal {
455    CURRENT_PRINCIPAL
456        .with(|slot| slot.borrow().clone())
457        .unwrap_or(AuthenticatedPrincipal::FirstParty)
458}
459
460pub(crate) fn principal_is_first_party(principal: &AuthenticatedPrincipal) -> bool {
461    matches!(
462        principal,
463        AuthenticatedPrincipal::FirstParty
464            | AuthenticatedPrincipal::RouteBind {
465                trust: PrincipalTrust::FirstParty,
466                ..
467            }
468    )
469}
470
471#[cfg(unix)]
472pub(crate) fn approved_payload_environment(
473    overrides: &HashMap<String, String>,
474    temp_dir: &Path,
475) -> ChildEnvironment {
476    sandboxed_child_environment(overrides, temp_dir)
477}
478
479#[cfg(unix)]
480#[allow(clippy::too_many_arguments)]
481pub(crate) fn mint_host_escalation_grant(
482    ctx: &AppContext,
483    principal: &AuthenticatedPrincipal,
484    command: &[u8],
485    root: &Path,
486    cwd: &Path,
487    shell_path: &Path,
488    environment: &ChildEnvironment,
489    storage_dir: &Path,
490    session_id: &str,
491) -> Result<String, String> {
492    mint_host_escalation_grant_at(
493        ctx,
494        principal,
495        command,
496        root,
497        cwd,
498        shell_path,
499        environment,
500        storage_dir,
501        session_id,
502        Instant::now(),
503    )
504}
505
506#[cfg(unix)]
507#[allow(clippy::too_many_arguments)]
508fn mint_host_escalation_grant_at(
509    ctx: &AppContext,
510    principal: &AuthenticatedPrincipal,
511    command: &[u8],
512    root: &Path,
513    cwd: &Path,
514    shell_path: &Path,
515    environment: &ChildEnvironment,
516    storage_dir: &Path,
517    session_id: &str,
518    now: Instant,
519) -> Result<String, String> {
520    let task = crate::bash_background::persistence::allocate_task_layout(storage_dir, session_id)
521        .map_err(|error| format!("failed to allocate escalation payload bundle: {error}"))?;
522    let prepared = match prepare_task_payload(
523        &task,
524        command,
525        root,
526        cwd,
527        principal,
528        shell_path,
529        environment,
530    ) {
531        Ok(prepared) => prepared,
532        Err(error) => {
533            let _ = crate::bash_background::persistence::delete_resolved_task(&task);
534            return Err(error);
535        }
536    };
537    let mut store = ctx.escalation_grants().lock();
538    let grant_id = loop {
539        let mut random = [0_u8; 16];
540        getrandom::fill(&mut random)
541            .map_err(|error| format!("failed to mint sandbox escalation grant: {error}"))?;
542        let candidate = format!("esc_{}", hex_bytes(&random));
543        if !store.grants.contains_key(&candidate) {
544            break candidate;
545        }
546    };
547    let grant = EscalationGrant {
548        principal: principal.clone(),
549        root: root.to_path_buf(),
550        digest: prepared.0.digest,
551        expires_at: now + ESCALATION_GRANT_TTL,
552        consumed: false,
553        session_dir: prepared.0.paths.session_dir.clone(),
554        task_id: prepared.0.paths.task_id.clone(),
555    };
556    store.grants.insert(grant_id.clone(), grant);
557    Ok(grant_id)
558}
559
560#[cfg(unix)]
561fn consume_host_escalation_grant_at(
562    ctx: &AppContext,
563    principal: &AuthenticatedPrincipal,
564    attempt: &HostEscalationAttempt,
565    now: Instant,
566) -> Result<PreparedTask, EscalationRefusal> {
567    let (digest, session_dir, task_id) = {
568        let mut store = ctx.escalation_grants().lock();
569        let Some(grant) = store.grants.get_mut(&attempt.grant_id) else {
570            return Err(EscalationRefusal::DigestMismatch);
571        };
572        if grant.consumed {
573            return Err(EscalationRefusal::Consumed);
574        }
575        if now >= grant.expires_at {
576            grant.consumed = true;
577            return Err(EscalationRefusal::Expired);
578        }
579        if grant.principal != *principal {
580            grant.consumed = true;
581            return Err(EscalationRefusal::WrongPrincipal);
582        }
583        if grant.root != attempt.root {
584            grant.consumed = true;
585            return Err(EscalationRefusal::DigestMismatch);
586        }
587        grant.consumed = true;
588        (
589            grant.digest,
590            grant.session_dir.clone(),
591            grant.task_id.clone(),
592        )
593    };
594
595    let task = crate::bash_background::persistence::resolve_uninitialized_task_layout(
596        &session_dir,
597        &task_id,
598    )
599    .map_err(|_| EscalationRefusal::DigestMismatch)?;
600    verify_payload(
601        task,
602        &attempt.command,
603        &attempt.root,
604        &attempt.cwd,
605        principal,
606        &attempt.shell_path,
607        &attempt.environment,
608        Some(digest),
609        true,
610    )
611    .map_err(|_| EscalationRefusal::DigestMismatch)
612}
613
614#[cfg(unix)]
615#[allow(clippy::too_many_arguments)]
616pub(crate) fn prepare_task_payload(
617    task: &crate::bash_background::persistence::ResolvedTask,
618    command: &[u8],
619    root: &Path,
620    cwd: &Path,
621    principal: &AuthenticatedPrincipal,
622    shell_path: &Path,
623    environment: &ChildEnvironment,
624) -> Result<PreparedTask, String> {
625    materialize_payload(
626        crate::bash_background::persistence::ResolvedTask {
627            paths: task.paths.clone(),
628            dirs: task.dirs.clone(),
629        },
630        command,
631        root,
632        cwd,
633        principal,
634        shell_path,
635        environment,
636    )
637}
638
639#[cfg(unix)]
640#[allow(clippy::too_many_arguments)]
641fn materialize_payload(
642    task: crate::bash_background::persistence::ResolvedTask,
643    command_bytes: &[u8],
644    root: &Path,
645    cwd: &Path,
646    principal: &AuthenticatedPrincipal,
647    shell_path: &Path,
648    environment: &ChildEnvironment,
649) -> Result<PreparedTask, String> {
650    let environment_bytes = encode_environment(environment);
651    let digest = payload_digest(
652        &task.paths.task_id,
653        command_bytes,
654        PAYLOAD_WRAPPER,
655        &environment_bytes,
656        root,
657        cwd,
658        principal,
659        shell_path,
660        environment,
661    );
662    crate::bash_background::persistence::create_control_file(
663        &task.dirs,
664        crate::bash_background::persistence::COMMAND_FILE,
665        command_bytes,
666    )
667    .map_err(|error| format!("failed to materialize command payload: {error}"))?;
668    crate::bash_background::persistence::create_control_file(
669        &task.dirs,
670        crate::bash_background::persistence::WRAPPER_FILE,
671        PAYLOAD_WRAPPER,
672    )
673    .map_err(|error| format!("failed to materialize wrapper payload: {error}"))?;
674    crate::bash_background::persistence::create_control_file(
675        &task.dirs,
676        crate::bash_background::persistence::ENVIRONMENT_FILE,
677        &environment_bytes,
678    )
679    .map_err(|error| format!("failed to materialize environment payload: {error}"))?;
680    crate::bash_background::persistence::create_control_file(
681        &task.dirs,
682        crate::bash_background::persistence::MANIFEST_FILE,
683        digest.as_bytes(),
684    )
685    .map_err(|error| format!("failed to materialize payload manifest: {error}"))?;
686    verify_payload(
687        task,
688        command_bytes,
689        root,
690        cwd,
691        principal,
692        shell_path,
693        environment,
694        Some(digest),
695        true,
696    )
697}
698
699#[cfg(unix)]
700#[allow(clippy::too_many_arguments)]
701fn verify_payload(
702    task: crate::bash_background::persistence::ResolvedTask,
703    expected_command: &[u8],
704    root: &Path,
705    cwd: &Path,
706    principal: &AuthenticatedPrincipal,
707    shell_path: &Path,
708    expected_environment: &ChildEnvironment,
709    expected_digest: Option<blake3::Hash>,
710    reject_extra_objects: bool,
711) -> Result<PreparedTask, String> {
712    if reject_extra_objects {
713        validate_payload_control_names(&task)?;
714    }
715
716    let mut command = crate::bash_background::persistence::open_control_file(
717        &task,
718        crate::bash_background::persistence::COMMAND_FILE,
719    )
720    .map_err(|error| format!("failed to open command payload: {error}"))?;
721    let mut wrapper = crate::bash_background::persistence::open_control_file(
722        &task,
723        crate::bash_background::persistence::WRAPPER_FILE,
724    )
725    .map_err(|error| format!("failed to open wrapper payload: {error}"))?;
726    let mut environment_file = crate::bash_background::persistence::open_control_file(
727        &task,
728        crate::bash_background::persistence::ENVIRONMENT_FILE,
729    )
730    .map_err(|error| format!("failed to open environment payload: {error}"))?;
731    let mut manifest = crate::bash_background::persistence::open_control_file(
732        &task,
733        crate::bash_background::persistence::MANIFEST_FILE,
734    )
735    .map_err(|error| format!("failed to open payload manifest: {error}"))?;
736
737    let command_bytes = read_held_payload(&mut command)?;
738    let wrapper_bytes = read_held_payload(&mut wrapper)?;
739    let environment_bytes = read_held_payload(&mut environment_file)?;
740    let manifest_bytes = read_held_payload(&mut manifest)?;
741    let environment = decode_environment(&environment_bytes)?;
742    let digest = payload_digest(
743        &task.paths.task_id,
744        &command_bytes,
745        &wrapper_bytes,
746        &environment_bytes,
747        root,
748        cwd,
749        principal,
750        shell_path,
751        expected_environment,
752    );
753    if command_bytes != expected_command
754        || environment != *expected_environment
755        || manifest_bytes.as_slice() != digest.as_bytes()
756        || expected_digest.is_some_and(|expected| expected != digest)
757    {
758        return Err("escalation payload manifest digest mismatch".to_string());
759    }
760    if reject_extra_objects {
761        validate_payload_control_names(&task)?;
762    }
763    command
764        .seek(SeekFrom::Start(0))
765        .map_err(|error| format!("failed to rewind command payload: {error}"))?;
766    wrapper
767        .seek(SeekFrom::Start(0))
768        .map_err(|error| format!("failed to rewind wrapper payload: {error}"))?;
769    environment_file
770        .seek(SeekFrom::Start(0))
771        .map_err(|error| format!("failed to rewind environment payload: {error}"))?;
772    Ok(PreparedTask(Arc::new(PreparedTaskInner {
773        paths: task.paths,
774        dirs: task.dirs,
775        digest,
776        environment,
777        command_bytes: Arc::new(command_bytes),
778        wrapper_bytes: Arc::new(wrapper_bytes),
779        _command_file: Arc::new(command),
780        _wrapper_file: Arc::new(wrapper),
781        _environment_file: Arc::new(environment_file),
782    })))
783}
784
785#[cfg(unix)]
786fn validate_payload_control_names(
787    task: &crate::bash_background::persistence::ResolvedTask,
788) -> Result<(), String> {
789    let mut names = task
790        .dirs
791        .control
792        .list_names()
793        .map_err(|error| format!("failed to enumerate escalation payload: {error}"))?;
794    names.sort();
795    let mut expected = [
796        OsString::from(crate::bash_background::persistence::COMMAND_FILE),
797        OsString::from(crate::bash_background::persistence::ENVIRONMENT_FILE),
798        OsString::from(crate::bash_background::persistence::MANIFEST_FILE),
799        OsString::from(crate::bash_background::persistence::WRAPPER_FILE),
800    ];
801    expected.sort();
802    if names.as_slice() != expected.as_slice() {
803        return Err(format!(
804            "escalation payload contains a missing or extra object: {names:?}"
805        ));
806    }
807    Ok(())
808}
809
810#[cfg(unix)]
811fn read_held_payload(file: &mut File) -> Result<Vec<u8>, String> {
812    file.seek(SeekFrom::Start(0))
813        .map_err(|error| format!("failed to rewind held payload: {error}"))?;
814    let mut bytes = Vec::new();
815    file.read_to_end(&mut bytes)
816        .map_err(|error| format!("failed to read held payload: {error}"))?;
817    Ok(bytes)
818}
819
820#[cfg(unix)]
821#[allow(clippy::too_many_arguments)]
822fn payload_digest(
823    task_id: &str,
824    command: &[u8],
825    wrapper: &[u8],
826    environment_bytes: &[u8],
827    root: &Path,
828    cwd: &Path,
829    principal: &AuthenticatedPrincipal,
830    shell_path: &Path,
831    environment: &ChildEnvironment,
832) -> blake3::Hash {
833    let mut hasher = blake3::Hasher::new();
834    hash_field(&mut hasher, ESCALATION_DIGEST_TAG);
835    hash_field(&mut hasher, task_id.as_bytes());
836    for (role, name, bytes) in [
837        (
838            b"command".as_slice(),
839            crate::bash_background::persistence::COMMAND_FILE,
840            command,
841        ),
842        (
843            b"wrapper".as_slice(),
844            crate::bash_background::persistence::WRAPPER_FILE,
845            wrapper,
846        ),
847        (
848            b"environment".as_slice(),
849            crate::bash_background::persistence::ENVIRONMENT_FILE,
850            environment_bytes,
851        ),
852    ] {
853        hash_field(&mut hasher, role);
854        hash_field(&mut hasher, name.as_bytes());
855        hash_field(&mut hasher, bytes);
856    }
857    hash_field(&mut hasher, &os_bytes(root.as_os_str()));
858    hash_field(&mut hasher, &os_bytes(cwd.as_os_str()));
859    hash_principal(&mut hasher, principal);
860    hash_field(&mut hasher, &os_bytes(shell_path.as_os_str()));
861    hash_field(&mut hasher, env!("CARGO_PKG_VERSION").as_bytes());
862    hash_field(&mut hasher, ESCALATION_TIER);
863    hasher.update(&(environment.len() as u64).to_be_bytes());
864    for (key, value) in environment {
865        hash_field(&mut hasher, &os_bytes(key));
866        hash_field(&mut hasher, &os_bytes(value));
867    }
868    hasher.finalize()
869}
870
871#[cfg(unix)]
872fn encode_environment(environment: &ChildEnvironment) -> Vec<u8> {
873    let mut bytes = Vec::new();
874    bytes.extend_from_slice(ENVIRONMENT_TAG);
875    bytes.extend_from_slice(&(environment.len() as u64).to_be_bytes());
876    for (key, value) in environment {
877        let key = os_bytes(key);
878        let value = os_bytes(value);
879        bytes.extend_from_slice(&(key.len() as u64).to_be_bytes());
880        bytes.extend_from_slice(&key);
881        bytes.extend_from_slice(&(value.len() as u64).to_be_bytes());
882        bytes.extend_from_slice(&value);
883    }
884    bytes
885}
886
887#[cfg(unix)]
888fn decode_environment(bytes: &[u8]) -> Result<ChildEnvironment, String> {
889    use std::os::unix::ffi::OsStringExt;
890
891    let mut cursor = ENVIRONMENT_TAG.len();
892    if !bytes.starts_with(ENVIRONMENT_TAG) {
893        return Err("invalid environment payload tag".to_string());
894    }
895    let count = read_u64(bytes, &mut cursor)?;
896    let mut environment = ChildEnvironment::new();
897    for _ in 0..count {
898        let key_len = read_u64(bytes, &mut cursor)? as usize;
899        let key = take_bytes(bytes, &mut cursor, key_len)?;
900        let value_len = read_u64(bytes, &mut cursor)? as usize;
901        let value = take_bytes(bytes, &mut cursor, value_len)?;
902        if environment
903            .insert(OsString::from_vec(key), OsString::from_vec(value))
904            .is_some()
905        {
906            return Err("duplicate key in environment payload".to_string());
907        }
908    }
909    if cursor != bytes.len() {
910        return Err("trailing bytes in environment payload".to_string());
911    }
912    Ok(environment)
913}
914
915#[cfg(unix)]
916fn read_u64(bytes: &[u8], cursor: &mut usize) -> Result<u64, String> {
917    let field = take_bytes(bytes, cursor, 8)?;
918    Ok(u64::from_be_bytes(field.try_into().map_err(|_| {
919        "invalid environment length field".to_string()
920    })?))
921}
922
923#[cfg(unix)]
924fn take_bytes(bytes: &[u8], cursor: &mut usize, len: usize) -> Result<Vec<u8>, String> {
925    let end = cursor
926        .checked_add(len)
927        .filter(|end| *end <= bytes.len())
928        .ok_or_else(|| "truncated environment payload".to_string())?;
929    let value = bytes[*cursor..end].to_vec();
930    *cursor = end;
931    Ok(value)
932}
933
934#[cfg(unix)]
935fn hash_principal(hasher: &mut blake3::Hasher, principal: &AuthenticatedPrincipal) {
936    match principal {
937        AuthenticatedPrincipal::FirstParty => hash_field(hasher, b"first_party"),
938        AuthenticatedPrincipal::RouteBind {
939            trust,
940            route_channel,
941            route_epoch,
942            project_root,
943            harness,
944            session_id,
945            principal_id,
946        } => {
947            hash_field(hasher, b"route_bind");
948            hash_field(
949                hasher,
950                match trust {
951                    PrincipalTrust::FirstParty => b"first_party",
952                    PrincipalTrust::Untrusted => b"untrusted",
953                },
954            );
955            hasher.update(&route_channel.to_be_bytes());
956            hasher.update(&route_epoch.to_be_bytes());
957            hash_field(hasher, &os_bytes(project_root.as_os_str()));
958            hash_field(hasher, harness.as_bytes());
959            hash_field(hasher, session_id.as_bytes());
960            match principal_id {
961                Some(id) => {
962                    hasher.update(&[1]);
963                    hash_field(hasher, id.as_bytes());
964                }
965                None => {
966                    hasher.update(&[0]);
967                }
968            }
969        }
970    }
971}
972
973#[cfg(unix)]
974fn hash_field(hasher: &mut blake3::Hasher, bytes: &[u8]) {
975    hasher.update(&(bytes.len() as u64).to_be_bytes());
976    hasher.update(bytes);
977}
978
979#[cfg(unix)]
980fn os_bytes(value: &OsStr) -> Vec<u8> {
981    use std::os::unix::ffi::OsStrExt;
982    value.as_bytes().to_vec()
983}
984
985#[cfg(unix)]
986fn hex_bytes(bytes: &[u8]) -> String {
987    const HEX: &[u8; 16] = b"0123456789abcdef";
988    let mut out = String::with_capacity(bytes.len() * 2);
989    for byte in bytes {
990        out.push(HEX[(byte >> 4) as usize] as char);
991        out.push(HEX[(byte & 0x0f) as usize] as char);
992    }
993    out
994}
995
996/// Returns true on Unix when native sandboxing is enabled for a first-party caller.
997///
998/// Process spawning and in-process bash rewriting both use this check so the
999/// command executes through the configured sandbox instead of being rewritten
1000/// to run inside the unsandboxed AFT process.
1001pub(crate) fn native_sandbox_enforced(
1002    ctx: &AppContext,
1003    principal: &AuthenticatedPrincipal,
1004) -> bool {
1005    cfg!(unix) && ctx.config().sandbox.enabled && principal_is_first_party(principal)
1006}
1007
1008pub(crate) fn unsupported_platform_sandbox_refusal(ctx: &AppContext) -> Option<SpawnPlan> {
1009    (ctx.config().sandbox.enabled && !cfg!(unix)).then(|| SpawnPlan::Refused {
1010        code: "sandbox_unavailable",
1011        message: "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
1012            .to_string(),
1013        mismatch_class: None,
1014    })
1015}
1016
1017/// Resolve policy for an agent-command process.
1018///
1019/// `task_bundle_dir` must be the already-created directory that owns the task's
1020/// capture files. The native builder creates a fresh private temp directory
1021/// beneath it and includes both directories in the profile.
1022pub fn resolve_sandbox_spawn(
1023    ctx: &AppContext,
1024    principal: &AuthenticatedPrincipal,
1025    requested_tier: RequestedSandboxTier,
1026    task_kind: SandboxTaskKind,
1027    task_bundle_dir: &Path,
1028    host_escalation: Option<&HostEscalationAttempt>,
1029) -> SpawnPlan {
1030    note_test_observation(ctx, principal, requested_tier, task_kind);
1031
1032    // This check must remain ahead of every platform and production-policy
1033    // branch. Windows tests use it to avoid entering process paths that cannot
1034    // consume native launcher plans.
1035    #[cfg(test)]
1036    if let Some(plan) = TEST_PLAN_OVERRIDE.with(|slot| slot.borrow().clone()) {
1037        return plan;
1038    }
1039
1040    // An enabled policy must never degrade into an ordinary child merely
1041    // because this build has no kernel sandbox backend.
1042    if let Some(refusal) = unsupported_platform_sandbox_refusal(ctx) {
1043        return refusal;
1044    }
1045
1046    if requested_tier == RequestedSandboxTier::Disabled || !ctx.config().sandbox.enabled {
1047        return SpawnPlan::Unsandboxed;
1048    }
1049
1050    if requested_tier == RequestedSandboxTier::Host {
1051        if !principal_is_first_party(principal) {
1052            return SpawnPlan::Refused {
1053                code: "sandbox_escalation_denied",
1054                message: "sandbox host escalation is unavailable to untrusted principals"
1055                    .to_string(),
1056                mismatch_class: Some("wrong_principal"),
1057            };
1058        }
1059
1060        #[cfg(windows)]
1061        {
1062            let _ = (ctx, task_kind, task_bundle_dir, host_escalation);
1063            unreachable!("unsupported platforms return before host-tier resolution");
1064        }
1065
1066        #[cfg(unix)]
1067        {
1068            let Some(attempt) = host_escalation else {
1069                return escalation_refused(EscalationRefusal::DigestMismatch);
1070            };
1071            return match consume_host_escalation_grant_at(ctx, principal, attempt, Instant::now()) {
1072                Ok(prepared) => SpawnPlan::Host {
1073                    shell_path: attempt.shell_path.clone(),
1074                    environment: prepared.environment().clone(),
1075                }
1076                .with_prepared_task(prepared),
1077                Err(refusal) => escalation_refused(refusal),
1078            };
1079        }
1080
1081        #[cfg(all(not(unix), not(windows)))]
1082        {
1083            let _ = (ctx, task_kind, task_bundle_dir, host_escalation);
1084            unreachable!("unsupported platforms return before host-tier resolution");
1085        }
1086    }
1087
1088    if !native_sandbox_enforced(ctx, principal) {
1089        return SpawnPlan::Unsandboxed;
1090    }
1091
1092    #[cfg(windows)]
1093    {
1094        let _ = (ctx, task_kind, task_bundle_dir);
1095        unreachable!("unsupported platforms return before native-tier resolution")
1096    }
1097
1098    #[cfg(unix)]
1099    {
1100        let profile = match build_native_profile(ctx, principal, task_bundle_dir) {
1101            Ok(profile) => profile,
1102            Err(error) => {
1103                return SpawnPlan::Refused {
1104                    code: "sandbox_unavailable",
1105                    message: format!(
1106                        "native sandbox setup failed: {error}; set sandbox.enabled=false to disable native sandboxing"
1107                    ),
1108                    mismatch_class: None,
1109                };
1110            }
1111        };
1112        let launcher_path = match std::env::current_exe() {
1113            Ok(path) => path,
1114            Err(error) => {
1115                let _ = std::fs::remove_dir_all(&profile.temp_dir);
1116                return SpawnPlan::Refused {
1117                    code: "sandbox_unavailable",
1118                    message: format!(
1119                        "native sandbox setup failed to locate the aft executable: {error}; set sandbox.enabled=false to disable native sandboxing"
1120                    ),
1121                    mismatch_class: None,
1122                };
1123            }
1124        };
1125        crate::slog_info!(
1126            "sandbox profile apply: tier=native task_kind={task_kind:?} writable_roots={} read_deny={}",
1127            profile.writable_roots.len(),
1128            profile.read_deny.len()
1129        );
1130        crate::slog_debug!(
1131            "sandbox profile paths: tier=native writable_roots={:?} write_deny_nested={:?} read_deny={:?} socket_deny={:?} cache_roots={:?} temp_dir={:?}",
1132            profile.writable_roots,
1133            profile.write_deny_nested,
1134            profile.read_deny,
1135            profile.socket_deny,
1136            profile.cache_roots,
1137            profile.temp_dir
1138        );
1139        SpawnPlan::Launcher {
1140            profile,
1141            launcher_path,
1142        }
1143    }
1144
1145    #[cfg(all(not(unix), not(windows)))]
1146    {
1147        let _ = (ctx, principal, task_kind, task_bundle_dir);
1148        unreachable!("unsupported platforms return before native-tier resolution")
1149    }
1150}
1151
1152#[cfg(unix)]
1153fn escalation_refused(refusal: EscalationRefusal) -> SpawnPlan {
1154    let class = refusal.class();
1155    SpawnPlan::Refused {
1156        code: "sandbox_escalation_denied",
1157        message: format!("sandbox host escalation grant refused: {class}"),
1158        mismatch_class: Some(class),
1159    }
1160}
1161
1162#[cfg(unix)]
1163fn build_native_profile(
1164    ctx: &AppContext,
1165    principal: &AuthenticatedPrincipal,
1166    task_bundle_dir: &Path,
1167) -> Result<SandboxProfile, String> {
1168    let home = std::env::var_os("HOME")
1169        .filter(|value| !value.is_empty())
1170        .map(PathBuf::from)
1171        .ok_or_else(|| {
1172            "HOME is not set, so credential and cache paths cannot be resolved".to_string()
1173        })?;
1174    if !home.is_absolute() {
1175        return Err(format!("HOME must be absolute: {}", home.display()));
1176    }
1177    let home = home
1178        .canonicalize()
1179        .map_err(|error| format!("failed to canonicalize HOME {}: {error}", home.display()))?;
1180    if !home.is_dir() {
1181        return Err(format!("HOME is not a directory: {}", home.display()));
1182    }
1183
1184    let mut project_roots = Vec::new();
1185    if let Some(root) = &ctx.config().project_root {
1186        project_roots.push(root.clone());
1187    }
1188    if let AuthenticatedPrincipal::RouteBind { project_root, .. } = principal {
1189        if !project_roots.contains(project_root) {
1190            project_roots.push(project_root.clone());
1191        }
1192    }
1193    if project_roots.is_empty() {
1194        project_roots.push(
1195            std::env::current_dir()
1196                .map_err(|error| format!("failed to resolve the current project root: {error}"))?,
1197        );
1198    }
1199
1200    for root in &mut project_roots {
1201        if !root.is_dir() {
1202            return Err(format!(
1203                "project root is not an existing directory: {}",
1204                root.display()
1205            ));
1206        }
1207        *root = root.canonicalize().map_err(|error| {
1208            format!(
1209                "failed to canonicalize project root {}: {error}",
1210                root.display()
1211            )
1212        })?;
1213    }
1214    project_roots.sort_unstable();
1215    project_roots.dedup();
1216
1217    if !task_bundle_dir.is_dir() {
1218        return Err(format!(
1219            "task io directory is not an existing directory: {}",
1220            task_bundle_dir.display()
1221        ));
1222    }
1223    let task_io_dir = task_bundle_dir
1224        .canonicalize()
1225        .map_err(|error| format!("failed to canonicalize task io directory: {error}"))?;
1226    let session_store = session_store_for_task_io(&task_io_dir)?;
1227
1228    let git_policies = project_roots
1229        .iter()
1230        .map(|root| resolve_git_policy(root))
1231        .collect::<Result<Vec<_>, _>>()?;
1232    let temp_dir = create_task_temp_dir(&task_io_dir)?;
1233    let result = (|| {
1234        let mut writable_roots = project_roots.clone();
1235        writable_roots.push(task_io_dir.clone());
1236        writable_roots.extend(
1237            ctx.config()
1238                .sandbox
1239                .write_allow
1240                .iter()
1241                .map(|path| expand_home(path, &home)),
1242        );
1243
1244        let secret_floor = vec![
1245            home.join(".ssh"),
1246            home.join(".aws"),
1247            home.join(".gnupg"),
1248            home.join(".config/gcloud"),
1249            home.join(".azure"),
1250            home.join(".config/cortexkit"),
1251        ];
1252        // The credential floor denies both read and write. Linux rejects any
1253        // writable overlap because Landlock cannot subtract write rights.
1254        let write_deny = secret_floor.clone();
1255        #[cfg(target_os = "macos")]
1256        let mut write_deny = write_deny;
1257        let mut write_deny_nested = Vec::new();
1258        let mut read_deny = secret_floor;
1259        for (root, git_policy) in project_roots.iter().zip(&git_policies) {
1260            #[cfg(target_os = "linux")]
1261            write_deny_nested.push(root.join(".git"));
1262            write_deny_nested.push(root.join(".cortexkit"));
1263            #[cfg(target_os = "macos")]
1264            write_deny.extend(git_policy.hooks.iter().cloned());
1265            read_deny.extend(git_policy.hooks.iter().cloned());
1266        }
1267        #[cfg(target_os = "linux")]
1268        read_deny.extend([
1269            PathBuf::from("/run/user"),
1270            PathBuf::from("/run/credentials"),
1271            PathBuf::from("/run/secrets"),
1272        ]);
1273        read_deny.extend(
1274            ctx.config()
1275                .sandbox
1276                .read_deny
1277                .iter()
1278                .map(|path| expand_home(path, &home)),
1279        );
1280
1281        let mut cache_roots = vec![
1282            home.join(".cargo/registry"),
1283            home.join(".cargo/git"),
1284            home.join(".rustup/downloads"),
1285            home.join(".npm"),
1286            home.join(".bun/install/cache"),
1287            home.join(".cache/pip"),
1288            home.join(".cache/uv"),
1289            home.join(".cache/go-build"),
1290            home.join(".gradle/caches"),
1291            home.join(".m2/repository"),
1292        ];
1293        #[cfg(target_os = "macos")]
1294        cache_roots.extend([
1295            home.join("Library/Caches/pip"),
1296            home.join("Library/Caches/uv"),
1297            home.join("Library/Caches/go-build"),
1298        ]);
1299        cache_roots.retain(|path| path.is_dir());
1300
1301        let mut socket_deny = vec![PathBuf::from("/var/run/docker.sock")];
1302        if let Some(agent_socket) =
1303            std::env::var_os("SSH_AUTH_SOCK").filter(|value| !value.is_empty())
1304        {
1305            socket_deny.push(PathBuf::from(agent_socket));
1306        }
1307
1308        let mut profile = SandboxProfile::build(
1309            writable_roots,
1310            write_deny,
1311            write_deny_nested,
1312            Vec::new(),
1313            read_deny,
1314            socket_deny,
1315            cache_roots,
1316            temp_dir.clone(),
1317        )
1318        .map_err(|error| error.to_string())?;
1319        // Seatbelt starts from allow-all reads, so it must deny the complete
1320        // store. Landlock instead omits the store while splitting read grants,
1321        // then adds only the prepared task's exact payload files.
1322        #[cfg(target_os = "macos")]
1323        if !profile.read_deny.contains(&session_store) {
1324            profile.read_deny.push(session_store.clone());
1325        }
1326        refuse_store_overlap(&profile, &session_store, &task_io_dir)?;
1327
1328        #[cfg(target_os = "linux")]
1329        let profile = {
1330            let git_read_roots = git_policies
1331                .iter()
1332                .flat_map(|policy| policy.read_roots.iter().cloned())
1333                .collect::<Vec<_>>();
1334            profile.read_allow = build_linux_read_allow(
1335                &profile,
1336                &home,
1337                &git_read_roots,
1338                std::slice::from_ref(&session_store),
1339            )?;
1340            profile = profile
1341                .canonicalize_for_launch()
1342                .map_err(|error| error.to_string())?;
1343            validate_final_read_rules(&profile.read_allow, &profile.read_deny)?;
1344            assert!(
1345                validate_final_read_rules(&profile.read_allow, &profile.read_deny).is_ok(),
1346                "final Landlock read grants overlap a denied path"
1347            );
1348            profile
1349        };
1350
1351        Ok(profile)
1352    })();
1353    if result.is_err() {
1354        let _ = std::fs::remove_dir_all(&temp_dir);
1355    }
1356    result
1357}
1358
1359#[cfg(unix)]
1360pub(crate) fn control_payload_read_grants(task_io: &Path) -> Result<Vec<PathBuf>, String> {
1361    if task_io.file_name() != Some(OsStr::new("io")) {
1362        return Err("task payload grants require the directory-layout io path".to_string());
1363    }
1364    let task_dir = task_io
1365        .parent()
1366        .ok_or_else(|| "task io directory has no task parent".to_string())?;
1367    let task_id = task_dir
1368        .file_name()
1369        .and_then(OsStr::to_str)
1370        .ok_or_else(|| "task directory has no UTF-8 identity".to_string())?;
1371    crate::bash_background::persistence::validate_task_id(task_id)
1372        .map_err(|error| error.to_string())?;
1373    let control = task_dir.join("control");
1374    Ok(vec![
1375        control.join(crate::bash_background::persistence::COMMAND_FILE),
1376        control.join(crate::bash_background::persistence::WRAPPER_FILE),
1377        control.join(crate::bash_background::persistence::ENVIRONMENT_FILE),
1378    ])
1379}
1380
1381#[cfg(unix)]
1382fn session_store_for_task_io(task_io: &Path) -> Result<PathBuf, String> {
1383    let Some(task_dir) = task_io.parent() else {
1384        return Err("task io directory has no task parent".to_string());
1385    };
1386    let directory_layout = task_io.file_name() == Some(OsStr::new("io"))
1387        && task_dir
1388            .file_name()
1389            .and_then(OsStr::to_str)
1390            .is_some_and(|task_id| {
1391                crate::bash_background::persistence::validate_task_id(task_id).is_ok()
1392            });
1393    let candidate = if directory_layout {
1394        task_dir
1395            .parent()
1396            .ok_or_else(|| "task directory has no session parent".to_string())?
1397    } else {
1398        task_io
1399    };
1400    candidate
1401        .canonicalize()
1402        .map_err(|error| format!("failed to canonicalize bash task session store: {error}"))
1403}
1404
1405#[cfg(unix)]
1406fn refuse_store_overlap(
1407    profile: &SandboxProfile,
1408    session_store: &Path,
1409    task_io: &Path,
1410) -> Result<(), String> {
1411    for root in profile.write_allow_roots() {
1412        if root == task_io || root.starts_with(task_io) {
1413            continue;
1414        }
1415        if root == session_store
1416            || root.starts_with(session_store)
1417            || session_store.starts_with(root)
1418        {
1419            return Err(format!(
1420                "sandbox writable root overlaps the bash task session store: writable={} store={}",
1421                root.display(),
1422                session_store.display()
1423            ));
1424        }
1425    }
1426    Ok(())
1427}
1428
1429#[cfg(unix)]
1430#[derive(Debug)]
1431struct GitPolicy {
1432    #[cfg(target_os = "linux")]
1433    read_roots: Vec<PathBuf>,
1434    hooks: Vec<PathBuf>,
1435}
1436
1437#[cfg(unix)]
1438fn resolve_git_policy(project_root: &Path) -> Result<GitPolicy, String> {
1439    let dot_git = project_root.join(".git");
1440    let metadata = match std::fs::symlink_metadata(&dot_git) {
1441        Ok(metadata) => metadata,
1442        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1443            return Ok(GitPolicy {
1444                #[cfg(target_os = "linux")]
1445                read_roots: Vec::new(),
1446                hooks: vec![dot_git.join("hooks")],
1447            });
1448        }
1449        Err(error) => {
1450            return Err(format!(
1451                "failed to inspect Git metadata {}: {error}",
1452                dot_git.display()
1453            ));
1454        }
1455    };
1456    if metadata.file_type().is_symlink() {
1457        return Err(format!(
1458            "refusing sandbox profile with symlinked Git metadata: {}",
1459            dot_git.display()
1460        ));
1461    }
1462
1463    let git_dir = if metadata.is_dir() {
1464        dot_git.canonicalize().map_err(|error| {
1465            format!(
1466                "failed to canonicalize Git directory {}: {error}",
1467                dot_git.display()
1468            )
1469        })?
1470    } else if metadata.is_file() {
1471        let pointer = std::fs::read_to_string(&dot_git).map_err(|error| {
1472            format!(
1473                "failed to read linked-worktree Git pointer {}: {error}",
1474                dot_git.display()
1475            )
1476        })?;
1477        let pointer = pointer
1478            .trim()
1479            .strip_prefix("gitdir:")
1480            .map(str::trim)
1481            .filter(|path| !path.is_empty())
1482            .ok_or_else(|| {
1483                format!(
1484                    "linked-worktree Git pointer is malformed: {}",
1485                    dot_git.display()
1486                )
1487            })?;
1488        let pointer = PathBuf::from(pointer);
1489        let pointer = if pointer.is_absolute() {
1490            pointer
1491        } else {
1492            project_root.join(pointer)
1493        };
1494        pointer.canonicalize().map_err(|error| {
1495            format!(
1496                "failed to resolve linked-worktree Git directory {}: {error}",
1497                pointer.display()
1498            )
1499        })?
1500    } else {
1501        return Err(format!(
1502            "Git metadata is neither a file nor directory: {}",
1503            dot_git.display()
1504        ));
1505    };
1506    if !git_dir.is_dir() {
1507        return Err(format!(
1508            "resolved Git directory is not a directory: {}",
1509            git_dir.display()
1510        ));
1511    }
1512
1513    let commondir_file = git_dir.join("commondir");
1514    let common_dir = match std::fs::read_to_string(&commondir_file) {
1515        Ok(value) => {
1516            let value = value.trim();
1517            if value.is_empty() {
1518                return Err(format!(
1519                    "Git commondir pointer is empty: {}",
1520                    commondir_file.display()
1521                ));
1522            }
1523            let value = PathBuf::from(value);
1524            let value = if value.is_absolute() {
1525                value
1526            } else {
1527                git_dir.join(value)
1528            };
1529            value.canonicalize().map_err(|error| {
1530                format!(
1531                    "failed to resolve Git commondir {}: {error}",
1532                    value.display()
1533                )
1534            })?
1535        }
1536        Err(error) if error.kind() == std::io::ErrorKind::NotFound => git_dir.clone(),
1537        Err(error) => {
1538            return Err(format!(
1539                "failed to read Git commondir {}: {error}",
1540                commondir_file.display()
1541            ));
1542        }
1543    };
1544    if !common_dir.is_dir() {
1545        return Err(format!(
1546            "resolved Git commondir is not a directory: {}",
1547            common_dir.display()
1548        ));
1549    }
1550
1551    let hooks = resolve_hooks_path(project_root, &common_dir)?;
1552    #[cfg(target_os = "linux")]
1553    let read_roots = {
1554        let mut read_roots = vec![git_dir, common_dir];
1555        read_roots.sort_unstable();
1556        read_roots.dedup();
1557        read_roots
1558    };
1559    Ok(GitPolicy {
1560        #[cfg(target_os = "linux")]
1561        read_roots,
1562        hooks: vec![hooks],
1563    })
1564}
1565
1566#[cfg(unix)]
1567fn resolve_hooks_path(project_root: &Path, common_dir: &Path) -> Result<PathBuf, String> {
1568    let configured = Command::new("git")
1569        .arg("-C")
1570        .arg(project_root)
1571        .args(["config", "--path", "core.hooksPath"])
1572        .output()
1573        .map_err(|error| {
1574            format!(
1575                "failed to query core.hooksPath for {}: {error}",
1576                project_root.display()
1577            )
1578        })?;
1579    if configured.status.success() {
1580        let configured = String::from_utf8(configured.stdout).map_err(|error| {
1581            format!(
1582                "core.hooksPath for {} is not UTF-8: {error}",
1583                project_root.display()
1584            )
1585        })?;
1586        if configured.trim().is_empty() {
1587            return Err(format!(
1588                "core.hooksPath for {} is empty",
1589                project_root.display()
1590            ));
1591        }
1592        let resolved = Command::new("git")
1593            .arg("-C")
1594            .arg(project_root)
1595            .args(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
1596            .output()
1597            .map_err(|error| {
1598                format!(
1599                    "failed to resolve core.hooksPath for {}: {error}",
1600                    project_root.display()
1601                )
1602            })?;
1603        if !resolved.status.success() {
1604            return Err(format!(
1605                "git could not resolve core.hooksPath for {}: {}",
1606                project_root.display(),
1607                String::from_utf8_lossy(&resolved.stderr).trim()
1608            ));
1609        }
1610        let resolved = String::from_utf8(resolved.stdout).map_err(|error| {
1611            format!(
1612                "resolved core.hooksPath for {} is not UTF-8: {error}",
1613                project_root.display()
1614            )
1615        })?;
1616        let resolved = PathBuf::from(resolved.trim());
1617        if !resolved.is_absolute() {
1618            return Err(format!(
1619                "git returned a non-absolute core.hooksPath for {}: {}",
1620                project_root.display(),
1621                resolved.display()
1622            ));
1623        }
1624        return canonicalize_policy_path(resolved, "core.hooksPath");
1625    }
1626
1627    if configured.status.code() != Some(1) || !configured.stdout.is_empty() {
1628        return Err(format!(
1629            "git could not query core.hooksPath for {}: {}",
1630            project_root.display(),
1631            String::from_utf8_lossy(&configured.stderr).trim()
1632        ));
1633    }
1634    canonicalize_policy_path(common_dir.join("hooks"), "Git hooks")
1635}
1636
1637#[cfg(unix)]
1638fn canonicalize_policy_path(path: PathBuf, field: &str) -> Result<PathBuf, String> {
1639    match path.canonicalize() {
1640        Ok(path) => Ok(path),
1641        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1642            let mut ancestor = path.clone();
1643            let mut tail = Vec::new();
1644            loop {
1645                match ancestor.canonicalize() {
1646                    Ok(mut canonical) => {
1647                        for component in tail.iter().rev() {
1648                            canonical.push(component);
1649                        }
1650                        return Ok(canonical);
1651                    }
1652                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1653                        let component =
1654                            ancestor.file_name().map(ToOwned::to_owned).ok_or_else(|| {
1655                                format!(
1656                                    "failed to canonicalize {field} path {}: {error}",
1657                                    path.display()
1658                                )
1659                            })?;
1660                        tail.push(component);
1661                        if !ancestor.pop() {
1662                            return Err(format!(
1663                                "failed to canonicalize {field} path {}: {error}",
1664                                path.display()
1665                            ));
1666                        }
1667                    }
1668                    Err(error) => {
1669                        return Err(format!(
1670                            "failed to canonicalize {field} path {}: {error}",
1671                            path.display()
1672                        ));
1673                    }
1674                }
1675            }
1676        }
1677        Err(error) => Err(format!(
1678            "failed to canonicalize {field} path {}: {error}",
1679            path.display()
1680        )),
1681    }
1682}
1683
1684#[cfg(any(test, target_os = "linux"))]
1685#[derive(Debug, Clone)]
1686struct IntendedReadGrant {
1687    path: PathBuf,
1688    force_children: bool,
1689    mandatory: bool,
1690}
1691
1692#[cfg(any(test, target_os = "linux"))]
1693#[derive(Debug, Clone, PartialEq, Eq)]
1694struct ListedReadChild {
1695    path: PathBuf,
1696    is_dir: bool,
1697}
1698
1699#[cfg(any(test, target_os = "linux"))]
1700trait ReadDirectoryLister {
1701    fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String>;
1702}
1703
1704#[cfg(target_os = "linux")]
1705fn build_linux_read_allow(
1706    profile: &SandboxProfile,
1707    home: &Path,
1708    git_read_roots: &[PathBuf],
1709    omitted_roots: &[PathBuf],
1710) -> Result<Vec<PathBuf>, String> {
1711    let mandatory_floor = &profile.write_deny;
1712    validate_mandatory_floor_overlap(profile.write_allow_roots(), mandatory_floor)?;
1713
1714    let mut intended = Vec::new();
1715    for path in [
1716        "/usr",
1717        "/bin",
1718        "/sbin",
1719        "/lib",
1720        "/lib32",
1721        "/lib64",
1722        "/etc",
1723        "/opt",
1724        "/run",
1725        "/proc",
1726        "/sys/devices/system/cpu",
1727        "/sys/fs/cgroup",
1728        "/dev/null",
1729        "/dev/zero",
1730        "/dev/full",
1731        "/dev/random",
1732        "/dev/urandom",
1733        "/dev/tty",
1734        "/dev/ptmx",
1735        "/dev/pts",
1736        "/dev/fd",
1737        "/dev/stdin",
1738        "/dev/stdout",
1739        "/dev/stderr",
1740    ] {
1741        if let Some(path) = canonicalize_existing_static(Path::new(path))? {
1742            intended.push(IntendedReadGrant {
1743                path,
1744                force_children: false,
1745                mandatory: true,
1746            });
1747        }
1748    }
1749    if let Some(path) = canonicalize_existing_static(Path::new("/var"))? {
1750        intended.push(IntendedReadGrant {
1751            path,
1752            // Enumerating /var avoids following the /var/run symlink back into /run.
1753            force_children: true,
1754            mandatory: true,
1755        });
1756    }
1757
1758    intended.push(IntendedReadGrant {
1759        path: home.to_path_buf(),
1760        force_children: true,
1761        mandatory: false,
1762    });
1763    intended.extend(
1764        profile
1765            .write_allow_roots()
1766            .into_iter()
1767            .map(|path| IntendedReadGrant {
1768                path: path.to_path_buf(),
1769                force_children: false,
1770                mandatory: false,
1771            }),
1772    );
1773    intended.extend(
1774        git_read_roots
1775            .iter()
1776            .cloned()
1777            .map(|path| IntendedReadGrant {
1778                path,
1779                force_children: false,
1780                mandatory: false,
1781            }),
1782    );
1783
1784    let split_denies = profile
1785        .read_deny
1786        .iter()
1787        .chain(omitted_roots)
1788        .cloned()
1789        .collect::<Vec<_>>();
1790    let mut lister = SecureReadDirectoryLister;
1791    split_read_grants(&intended, &split_denies, &mut lister)
1792}
1793
1794#[cfg(target_os = "linux")]
1795fn add_linux_payload_read_grants(
1796    profile: &mut SandboxProfile,
1797    payload_grants: &[PathBuf],
1798) -> Result<(), String> {
1799    let intended = payload_grants
1800        .iter()
1801        .map(|path| {
1802            let path = path.canonicalize().map_err(|error| {
1803                format!(
1804                    "mandatory payload read path is unavailable: {}: {error}",
1805                    path.display()
1806                )
1807            })?;
1808            Ok(IntendedReadGrant {
1809                path,
1810                force_children: false,
1811                mandatory: true,
1812            })
1813        })
1814        .collect::<Result<Vec<_>, String>>()?;
1815    let mut lister = SecureReadDirectoryLister;
1816    let payload_grants = split_read_grants(&intended, &profile.read_deny, &mut lister)?;
1817
1818    let mut final_read_allow = profile.read_allow.clone();
1819    final_read_allow.extend(payload_grants);
1820    final_read_allow.sort_unstable();
1821    final_read_allow.dedup();
1822    validate_final_read_rules(&final_read_allow, &profile.read_deny)?;
1823    assert!(
1824        validate_final_read_rules(&final_read_allow, &profile.read_deny).is_ok(),
1825        "final Landlock read grants overlap a denied path after adding payload files"
1826    );
1827    profile.read_allow = final_read_allow;
1828    Ok(())
1829}
1830
1831#[cfg(target_os = "linux")]
1832fn canonicalize_existing_static(path: &Path) -> Result<Option<PathBuf>, String> {
1833    match std::fs::symlink_metadata(path) {
1834        Ok(_) => match path.canonicalize() {
1835            Ok(path) => Ok(Some(path)),
1836            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1837            Err(error) => Err(format!(
1838                "failed to canonicalize static read root {}: {error}",
1839                path.display()
1840            )),
1841        },
1842        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1843        Err(error) => Err(format!(
1844            "failed to inspect static read root {}: {error}",
1845            path.display()
1846        )),
1847    }
1848}
1849
1850#[cfg(any(test, target_os = "linux"))]
1851fn split_read_grants(
1852    intended: &[IntendedReadGrant],
1853    denies: &[PathBuf],
1854    lister: &mut impl ReadDirectoryLister,
1855) -> Result<Vec<PathBuf>, String> {
1856    let mut emitted = BTreeSet::new();
1857    for grant in intended {
1858        split_read_grant(grant, denies, lister, &mut emitted)?;
1859    }
1860    let emitted = emitted.into_iter().collect::<Vec<_>>();
1861    validate_final_read_rules(&emitted, denies)?;
1862    Ok(emitted)
1863}
1864
1865#[cfg(any(test, target_os = "linux"))]
1866fn split_read_grant(
1867    grant: &IntendedReadGrant,
1868    denies: &[PathBuf],
1869    lister: &mut impl ReadDirectoryLister,
1870    emitted: &mut BTreeSet<PathBuf>,
1871) -> Result<(), String> {
1872    if let Some(deny) = denies
1873        .iter()
1874        .find(|deny| grant.path == **deny || grant.path.starts_with(deny))
1875    {
1876        if grant.mandatory {
1877            return Err(format!(
1878                "sandbox_unavailable: mandatory read root {} is denied by {}",
1879                grant.path.display(),
1880                deny.display()
1881            ));
1882        }
1883        return Ok(());
1884    }
1885
1886    let contains_deny = denies.iter().any(|deny| deny.starts_with(&grant.path));
1887    if !grant.force_children && !contains_deny {
1888        emitted.insert(grant.path.clone());
1889        return Ok(());
1890    }
1891
1892    let children = lister.children(&grant.path).map_err(|error| {
1893        format!(
1894            "sandbox_unavailable: cannot split read root {}: {error}",
1895            grant.path.display()
1896        )
1897    })?;
1898    for child in children {
1899        let child_contains_deny = denies.iter().any(|deny| deny.starts_with(&child.path));
1900        if child_contains_deny && !child.is_dir {
1901            return Err(format!(
1902                "sandbox_unavailable: deny chain crosses non-directory path {}",
1903                child.path.display()
1904            ));
1905        }
1906        split_read_grant(
1907            &IntendedReadGrant {
1908                path: child.path,
1909                force_children: false,
1910                mandatory: false,
1911            },
1912            denies,
1913            lister,
1914            emitted,
1915        )?;
1916    }
1917    Ok(())
1918}
1919
1920#[cfg(any(test, target_os = "linux"))]
1921fn validate_mandatory_floor_overlap<'a>(
1922    writable_roots: impl IntoIterator<Item = &'a Path>,
1923    mandatory_floor: &[PathBuf],
1924) -> Result<(), String> {
1925    for writable in writable_roots {
1926        for secret in mandatory_floor {
1927            if paths_overlap(writable, secret) {
1928                return Err(format!(
1929                    "writable root {} overlaps mandatory secret floor {}",
1930                    writable.display(),
1931                    secret.display()
1932                ));
1933            }
1934        }
1935    }
1936    Ok(())
1937}
1938
1939#[cfg(any(test, target_os = "linux"))]
1940fn validate_final_read_rules(read_allow: &[PathBuf], denies: &[PathBuf]) -> Result<(), String> {
1941    for grant in read_allow {
1942        for deny in denies {
1943            if paths_overlap(grant, deny) {
1944                return Err(format!(
1945                    "sandbox_unavailable: final read grant {} overlaps denied path {}",
1946                    grant.display(),
1947                    deny.display()
1948                ));
1949            }
1950        }
1951    }
1952    Ok(())
1953}
1954
1955#[cfg(any(test, target_os = "linux"))]
1956fn paths_overlap(left: &Path, right: &Path) -> bool {
1957    left == right || left.starts_with(right) || right.starts_with(left)
1958}
1959
1960#[cfg(target_os = "linux")]
1961struct SecureReadDirectoryLister;
1962
1963#[cfg(target_os = "linux")]
1964impl ReadDirectoryLister for SecureReadDirectoryLister {
1965    fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String> {
1966        let parent_fd = open_absolute_no_symlinks(parent, true)?;
1967        let readable_fd =
1968            open_directory_for_enumeration(parent_fd.as_raw_fd()).map_err(|error| {
1969                format!(
1970                    "failed to open directory for enumeration {}: {error}",
1971                    parent.display()
1972                )
1973            })?;
1974        let duplicate = unsafe { libc::dup(readable_fd.as_raw_fd()) };
1975        if duplicate < 0 {
1976            return Err(format!(
1977                "failed to duplicate directory fd for {}: {}",
1978                parent.display(),
1979                std::io::Error::last_os_error()
1980            ));
1981        }
1982        let directory = unsafe { libc::fdopendir(duplicate) };
1983        if directory.is_null() {
1984            let error = std::io::Error::last_os_error();
1985            unsafe { libc::close(duplicate) };
1986            return Err(format!(
1987                "failed to enumerate directory {}: {error}",
1988                parent.display()
1989            ));
1990        }
1991
1992        let result = (|| {
1993            let mut children = Vec::new();
1994            loop {
1995                unsafe { *libc::__errno_location() = 0 };
1996                let entry = unsafe { libc::readdir(directory) };
1997                if entry.is_null() {
1998                    let error = std::io::Error::last_os_error();
1999                    if error.raw_os_error() == Some(0) {
2000                        break;
2001                    }
2002                    return Err(format!(
2003                        "failed while enumerating {}: {error}",
2004                        parent.display()
2005                    ));
2006                }
2007                let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
2008                if name == b"." || name == b".." {
2009                    continue;
2010                }
2011                let name = OsStr::from_bytes(name);
2012                let diagnostic_path = parent.join(name);
2013                let diagnostic = std::fs::symlink_metadata(&diagnostic_path).map_err(|error| {
2014                    format!(
2015                        "directory entry changed while inspecting {}: {error}",
2016                        diagnostic_path.display()
2017                    )
2018                })?;
2019                if diagnostic.file_type().is_symlink() {
2020                    continue;
2021                }
2022
2023                let child_fd =
2024                    open_child_no_symlinks(parent_fd.as_raw_fd(), name).map_err(|error| {
2025                        format!(
2026                            "directory entry changed while opening {}: {error}",
2027                            diagnostic_path.display()
2028                        )
2029                    })?;
2030                let metadata = fstat_fd(&child_fd).map_err(|error| {
2031                    format!(
2032                        "failed to inspect opened directory entry {}: {error}",
2033                        diagnostic_path.display()
2034                    )
2035                })?;
2036                if metadata.st_mode & libc::S_IFMT == libc::S_IFLNK {
2037                    continue;
2038                }
2039                children.push(ListedReadChild {
2040                    path: diagnostic_path,
2041                    is_dir: metadata.st_mode & libc::S_IFMT == libc::S_IFDIR,
2042                });
2043            }
2044            children.sort_unstable_by(|left, right| left.path.cmp(&right.path));
2045            Ok(children)
2046        })();
2047        unsafe { libc::closedir(directory) };
2048        result
2049    }
2050}
2051
2052#[cfg(target_os = "linux")]
2053#[repr(C)]
2054struct OpenHow {
2055    flags: u64,
2056    mode: u64,
2057    resolve: u64,
2058}
2059
2060#[cfg(target_os = "linux")]
2061const RESOLVE_NO_SYMLINKS: u64 = 0x04;
2062#[cfg(target_os = "linux")]
2063const RESOLVE_BENEATH: u64 = 0x08;
2064
2065#[cfg(target_os = "linux")]
2066fn open_absolute_no_symlinks(path: &Path, directory: bool) -> Result<OwnedFd, String> {
2067    if !path.is_absolute() {
2068        return Err(format!("path is not absolute: {}", path.display()));
2069    }
2070    let root = unsafe {
2071        libc::open(
2072            c"/".as_ptr(),
2073            libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
2074        )
2075    };
2076    if root < 0 {
2077        return Err(format!(
2078            "failed to open filesystem root: {}",
2079            std::io::Error::last_os_error()
2080        ));
2081    }
2082    let root = unsafe { OwnedFd::from_raw_fd(root) };
2083    let components = normalized_relative_components(path)?;
2084    if components.is_empty() {
2085        return Ok(root);
2086    }
2087
2088    let relative = components
2089        .iter()
2090        .fold(PathBuf::new(), |path, component| path.join(component));
2091    let relative = CString::new(relative.as_os_str().as_bytes())
2092        .map_err(|_| format!("path contains NUL: {}", path.display()))?;
2093    let flags = libc::O_PATH | libc::O_CLOEXEC | if directory { libc::O_DIRECTORY } else { 0 };
2094    let how = OpenHow {
2095        flags: flags as u64,
2096        mode: 0,
2097        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
2098    };
2099    let opened = unsafe {
2100        libc::syscall(
2101            libc::SYS_openat2,
2102            root.as_raw_fd(),
2103            relative.as_ptr(),
2104            &how,
2105            std::mem::size_of::<OpenHow>(),
2106        ) as libc::c_int
2107    };
2108    if opened >= 0 {
2109        return Ok(unsafe { OwnedFd::from_raw_fd(opened) });
2110    }
2111    let error = std::io::Error::last_os_error();
2112    if error.raw_os_error() != Some(libc::ENOSYS) {
2113        return Err(format!(
2114            "secure open failed for {}: {error}",
2115            path.display()
2116        ));
2117    }
2118
2119    let mut current = root;
2120    for (index, component) in components.iter().enumerate() {
2121        let component = CString::new(component.as_bytes())
2122            .map_err(|_| format!("path contains NUL: {}", path.display()))?;
2123        let last = index + 1 == components.len();
2124        let mut flags = libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW;
2125        if !last || directory {
2126            flags |= libc::O_DIRECTORY;
2127        }
2128        let opened = unsafe { libc::openat(current.as_raw_fd(), component.as_ptr(), flags) };
2129        if opened < 0 {
2130            return Err(format!(
2131                "component-wise secure open failed for {}: {}",
2132                path.display(),
2133                std::io::Error::last_os_error()
2134            ));
2135        }
2136        let opened = unsafe { OwnedFd::from_raw_fd(opened) };
2137        let metadata = fstat_fd(&opened)
2138            .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?;
2139        if metadata.st_mode & libc::S_IFMT == libc::S_IFLNK {
2140            return Err(format!(
2141                "secure open encountered a symlink: {}",
2142                path.display()
2143            ));
2144        }
2145        current = opened;
2146    }
2147    Ok(current)
2148}
2149
2150#[cfg(target_os = "linux")]
2151fn open_directory_for_enumeration(parent_fd: i32) -> Result<OwnedFd, std::io::Error> {
2152    let how = OpenHow {
2153        flags: (libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) as u64,
2154        mode: 0,
2155        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
2156    };
2157    let opened = unsafe {
2158        libc::syscall(
2159            libc::SYS_openat2,
2160            parent_fd,
2161            c".".as_ptr(),
2162            &how,
2163            std::mem::size_of::<OpenHow>(),
2164        ) as libc::c_int
2165    };
2166    if opened >= 0 {
2167        return Ok(unsafe { OwnedFd::from_raw_fd(opened) });
2168    }
2169    let error = std::io::Error::last_os_error();
2170    if error.raw_os_error() != Some(libc::ENOSYS) {
2171        return Err(error);
2172    }
2173
2174    let opened = unsafe {
2175        libc::openat(
2176            parent_fd,
2177            c".".as_ptr(),
2178            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2179        )
2180    };
2181    if opened < 0 {
2182        Err(std::io::Error::last_os_error())
2183    } else {
2184        Ok(unsafe { OwnedFd::from_raw_fd(opened) })
2185    }
2186}
2187
2188#[cfg(target_os = "linux")]
2189fn open_child_no_symlinks(parent_fd: i32, name: &OsStr) -> Result<OwnedFd, std::io::Error> {
2190    let name = CString::new(name.as_bytes())
2191        .map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))?;
2192    let how = OpenHow {
2193        flags: (libc::O_PATH | libc::O_CLOEXEC) as u64,
2194        mode: 0,
2195        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
2196    };
2197    let opened = unsafe {
2198        libc::syscall(
2199            libc::SYS_openat2,
2200            parent_fd,
2201            name.as_ptr(),
2202            &how,
2203            std::mem::size_of::<OpenHow>(),
2204        ) as libc::c_int
2205    };
2206    if opened >= 0 {
2207        return Ok(unsafe { OwnedFd::from_raw_fd(opened) });
2208    }
2209    let error = std::io::Error::last_os_error();
2210    if error.raw_os_error() != Some(libc::ENOSYS) {
2211        return Err(error);
2212    }
2213
2214    let opened = unsafe {
2215        libc::openat(
2216            parent_fd,
2217            name.as_ptr(),
2218            libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2219        )
2220    };
2221    if opened < 0 {
2222        return Err(std::io::Error::last_os_error());
2223    }
2224    let opened = unsafe { OwnedFd::from_raw_fd(opened) };
2225    let metadata = fstat_fd(&opened)?;
2226    if metadata.st_mode & libc::S_IFMT == libc::S_IFLNK {
2227        return Err(std::io::Error::from_raw_os_error(libc::ELOOP));
2228    }
2229    Ok(opened)
2230}
2231
2232#[cfg(target_os = "linux")]
2233fn fstat_fd(fd: &OwnedFd) -> Result<libc::stat, std::io::Error> {
2234    let mut metadata = std::mem::MaybeUninit::<libc::stat>::uninit();
2235    if unsafe { libc::fstat(fd.as_raw_fd(), metadata.as_mut_ptr()) } < 0 {
2236        return Err(std::io::Error::last_os_error());
2237    }
2238    Ok(unsafe { metadata.assume_init() })
2239}
2240
2241#[cfg(target_os = "linux")]
2242fn normalized_relative_components(path: &Path) -> Result<Vec<&OsStr>, String> {
2243    let mut components = Vec::new();
2244    for component in path.components() {
2245        match component {
2246            std::path::Component::RootDir => {}
2247            std::path::Component::Normal(component) => components.push(component),
2248            _ => {
2249                return Err(format!(
2250                    "path is not normalized for secure open: {}",
2251                    path.display()
2252                ));
2253            }
2254        }
2255    }
2256    Ok(components)
2257}
2258
2259#[cfg(unix)]
2260fn expand_home(path: &Path, home: &Path) -> PathBuf {
2261    let mut components = path.components();
2262    if components
2263        .next()
2264        .is_some_and(|component| component.as_os_str() == "~")
2265    {
2266        return components.fold(home.to_path_buf(), |resolved, component| {
2267            resolved.join(component.as_os_str())
2268        });
2269    }
2270    path.to_path_buf()
2271}
2272
2273#[cfg(unix)]
2274fn create_task_temp_dir(task_bundle_dir: &Path) -> Result<PathBuf, String> {
2275    static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
2276    for _ in 0..32 {
2277        let nonce = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
2278        let path = task_bundle_dir.join(format!(".sandbox-tmp-{}-{nonce}", std::process::id()));
2279        match DirBuilder::new().mode(0o700).create(&path) {
2280            Ok(()) => {
2281                return path.canonicalize().map_err(|error| {
2282                    format!(
2283                        "failed to canonicalize task temp directory {}: {error}",
2284                        path.display()
2285                    )
2286                });
2287            }
2288            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2289            Err(error) => {
2290                return Err(format!(
2291                    "failed to create task temp directory {}: {error}",
2292                    path.display()
2293                ));
2294            }
2295        }
2296    }
2297    Err("failed to allocate a fresh task temp directory after 32 attempts".to_string())
2298}
2299
2300pub(crate) fn is_managed_task_temp_dir(path: &Path) -> bool {
2301    path.file_name()
2302        .and_then(OsStr::to_str)
2303        .is_some_and(|name| name.starts_with(".sandbox-tmp-"))
2304}
2305
2306fn note_test_observation(
2307    ctx: &AppContext,
2308    principal: &AuthenticatedPrincipal,
2309    requested_tier: RequestedSandboxTier,
2310    task_kind: SandboxTaskKind,
2311) {
2312    let Some(observations) = TEST_OBSERVATIONS.get() else {
2313        return;
2314    };
2315    let Some(project_root) = ctx.config().project_root.clone() else {
2316        return;
2317    };
2318    let project_root = observation_key(&project_root);
2319    if let Some(project) = observations
2320        .lock()
2321        .expect("sandbox spawn test observation mutex poisoned")
2322        .get_mut(&project_root)
2323    {
2324        project.push(SandboxSpawnObservation {
2325            principal: principal.clone(),
2326            requested_tier,
2327            task_kind,
2328        });
2329    }
2330}
2331
2332/// Start recording resolver calls for one project root.
2333#[doc(hidden)]
2334pub fn install_sandbox_spawn_test_seam(project_root: PathBuf) {
2335    TEST_OBSERVATIONS
2336        .get_or_init(|| Mutex::new(HashMap::new()))
2337        .lock()
2338        .expect("sandbox spawn test observation mutex poisoned")
2339        .insert(observation_key(&project_root), Vec::new());
2340}
2341
2342/// Snapshot resolver calls recorded for one project root.
2343#[doc(hidden)]
2344pub fn sandbox_spawn_test_observations(project_root: &Path) -> Vec<SandboxSpawnObservation> {
2345    TEST_OBSERVATIONS
2346        .get()
2347        .and_then(|observations| {
2348            observations
2349                .lock()
2350                .expect("sandbox spawn test observation mutex poisoned")
2351                .get(&observation_key(project_root))
2352                .cloned()
2353        })
2354        .unwrap_or_default()
2355}
2356
2357/// Remove one project-root resolver test seam.
2358#[doc(hidden)]
2359pub fn clear_sandbox_spawn_test_seam(project_root: &Path) {
2360    if let Some(observations) = TEST_OBSERVATIONS.get() {
2361        observations
2362            .lock()
2363            .expect("sandbox spawn test observation mutex poisoned")
2364            .remove(&observation_key(project_root));
2365    }
2366}
2367
2368fn observation_key(project_root: &Path) -> PathBuf {
2369    std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf())
2370}
2371
2372#[cfg(test)]
2373pub(crate) fn with_spawn_plan_for_test<R>(plan: SpawnPlan, run: impl FnOnce() -> R) -> R {
2374    struct PlanScope(Option<SpawnPlan>);
2375
2376    impl Drop for PlanScope {
2377        fn drop(&mut self) {
2378            TEST_PLAN_OVERRIDE.with(|slot| {
2379                slot.replace(self.0.take());
2380            });
2381        }
2382    }
2383
2384    let previous = TEST_PLAN_OVERRIDE.with(|slot| slot.replace(Some(plan)));
2385    let _scope = PlanScope(previous);
2386    run()
2387}
2388
2389/// Build a detached `Command` while enforcing the required launch plan.
2390///
2391/// Windows detached spawns route through the shell-candidate ladder, which
2392/// enforces the plan inline, so this helper is Unix-only.
2393#[cfg(unix)]
2394pub(crate) const CHILD_EXIT_FD: RawFd = 3;
2395#[cfg(unix)]
2396pub(crate) const CHILD_FAILURE_FD: RawFd = 4;
2397
2398#[cfg(unix)]
2399pub(crate) fn apply_marker_fd_allowlist(
2400    command: &mut Command,
2401    exit_fd: RawFd,
2402    failure_fd: RawFd,
2403) -> Result<(RawFd, RawFd), String> {
2404    use std::os::unix::process::CommandExt;
2405
2406    let fd_limit = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) };
2407    let fd_limit = if fd_limit > 0 {
2408        (fd_limit as RawFd).min(65_536)
2409    } else {
2410        1_024
2411    };
2412    unsafe {
2413        command.pre_exec(move || {
2414            let exit_copy = libc::fcntl(exit_fd, libc::F_DUPFD_CLOEXEC, 5);
2415            if exit_copy < 0 {
2416                return Err(std::io::Error::last_os_error());
2417            }
2418            let failure_copy = libc::fcntl(failure_fd, libc::F_DUPFD_CLOEXEC, 5);
2419            if failure_copy < 0 {
2420                let error = std::io::Error::last_os_error();
2421                libc::close(exit_copy);
2422                return Err(error);
2423            }
2424            if libc::dup2(exit_copy, CHILD_EXIT_FD) < 0
2425                || libc::dup2(failure_copy, CHILD_FAILURE_FD) < 0
2426            {
2427                let error = std::io::Error::last_os_error();
2428                libc::close(exit_copy);
2429                libc::close(failure_copy);
2430                return Err(error);
2431            }
2432            libc::close(exit_copy);
2433            libc::close(failure_copy);
2434            for fd in 5..fd_limit {
2435                let flags = libc::fcntl(fd, libc::F_GETFD);
2436                if flags >= 0 {
2437                    libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
2438                }
2439            }
2440            Ok(())
2441        });
2442    }
2443    Ok((CHILD_EXIT_FD, CHILD_FAILURE_FD))
2444}
2445
2446#[cfg(unix)]
2447pub(crate) fn detached_command_for_plan(
2448    plan: &SpawnPlan,
2449    program: &OsStr,
2450    args: &[OsString],
2451    task_marker: &Path,
2452    exit_fd: RawFd,
2453    failure_fd: RawFd,
2454) -> Result<(Command, Option<File>), String> {
2455    let (program, args, profile_handle) = command_argv_for_plan(
2456        plan,
2457        program,
2458        args,
2459        task_marker,
2460        Some((exit_fd, failure_fd)),
2461    )?;
2462    use std::os::unix::process::CommandExt;
2463
2464    let mut command = crate::effective_path::new_command(program);
2465    command.args(args).process_group(0);
2466    Ok((command, profile_handle))
2467}
2468
2469fn isolated_environment_for_plan(
2470    plan: &SpawnPlan,
2471    request_environment: &HashMap<String, String>,
2472) -> Option<ChildEnvironment> {
2473    #[cfg(unix)]
2474    if !matches!(plan.policy(), SpawnPlan::Unsandboxed) {
2475        if let Some(task) = plan.prepared_task() {
2476            return Some(task.environment().clone());
2477        }
2478    }
2479    match plan.policy() {
2480        SpawnPlan::Host { environment, .. } => Some(environment.clone()),
2481        SpawnPlan::Launcher { profile, .. } => Some(sandboxed_child_environment(
2482            request_environment,
2483            &profile.temp_dir,
2484        )),
2485        SpawnPlan::Unsandboxed | SpawnPlan::Refused { .. } => None,
2486        #[cfg(unix)]
2487        SpawnPlan::Prepared { .. } => unreachable!("policy() unwraps prepared plans"),
2488    }
2489}
2490
2491fn sandboxed_child_environment(
2492    request_environment: &HashMap<String, String>,
2493    temp_dir: &Path,
2494) -> ChildEnvironment {
2495    let mut environment = std::env::vars_os()
2496        .filter(|(key, _)| sandbox_base_environment_key(key))
2497        .collect::<ChildEnvironment>();
2498    // PATH must be AFT's enriched value rather than the daemon's original
2499    // value, which can omit package-manager and user tool locations.
2500    environment.insert(
2501        OsString::from("PATH"),
2502        crate::effective_path::effective_path().to_os_string(),
2503    );
2504    for (key, value) in request_environment {
2505        // A request-supplied environment reaches the OUTER launcher / `/bin/sh`
2506        // supervisor, which exec before Landlock/Seatbelt is installed. A
2507        // dynamic-loader or shell/interpreter startup hook here would execute
2508        // code OUTSIDE confinement, so those keys are dropped even though they
2509        // arrived through the (otherwise honored) request environment.
2510        if is_preexec_hijack_env_key(key.as_str()) {
2511            continue;
2512        }
2513        environment.insert(OsString::from(key), OsString::from(value));
2514    }
2515    for key in ["TMPDIR", "TEMP", "TMP"] {
2516        environment.insert(OsString::from(key), temp_dir.as_os_str().to_os_string());
2517    }
2518    environment
2519}
2520
2521fn sandbox_base_environment_key(key: &OsStr) -> bool {
2522    key.to_str().is_some_and(|key| {
2523        matches!(key, "HOME" | "USER" | "LOGNAME" | "SHELL" | "TERM" | "LANG")
2524            || key.starts_with("LC_")
2525    })
2526}
2527
2528/// Environment keys that make a process load a library or run code during its
2529/// own startup — before a native sandbox is installed in the launcher chain.
2530/// These are refused from the request environment regardless of value so an
2531/// injected `LD_PRELOAD` / `DYLD_INSERT_LIBRARIES` / `BASH_ENV` cannot execute
2532/// outside confinement. Matching is case-sensitive; POSIX environment names are
2533/// case-sensitive and the loaders only honor the exact upper-case spellings.
2534fn is_preexec_hijack_env_key(key: &str) -> bool {
2535    // Dynamic-loader families (glibc/musl `LD_*`, macdyld `DYLD_*`): the whole
2536    // prefix executes/loads at exec time, so block the family, not a fixed set.
2537    if key.starts_with("LD_") || key.starts_with("DYLD_") {
2538        return true;
2539    }
2540    matches!(
2541        key,
2542        // POSIX/bash shell startup + trace hooks (the supervisor is `/bin/sh`).
2543        "BASH_ENV"
2544            | "ENV"
2545            | "SHELLOPTS"
2546            | "BASHOPTS"
2547            | "PROMPT_COMMAND"
2548            | "PS4"
2549            | "IFS"
2550            // Interpreter auto-run / library-injection hooks.
2551            | "PYTHONSTARTUP"
2552            | "PYTHONPATH"
2553            | "PYTHONHOME"
2554            | "PERL5OPT"
2555            | "PERL5LIB"
2556            | "PERLLIB"
2557            | "PERL5DB"
2558            | "RUBYOPT"
2559            | "RUBYLIB"
2560            | "NODE_OPTIONS"
2561            // glibc auxiliary loader hooks.
2562            | "GCONV_PATH"
2563            | "LOCPATH"
2564            | "NLSPATH"
2565            | "HOSTALIASES"
2566            | "RESOLV_HOST_CONF"
2567    )
2568}
2569
2570#[cfg(unix)]
2571pub(crate) fn approved_environment_for_plan(
2572    plan: &SpawnPlan,
2573    request_environment: &HashMap<String, String>,
2574) -> ChildEnvironment {
2575    isolated_environment_for_plan(plan, request_environment)
2576        .unwrap_or_else(|| approved_payload_environment(request_environment, &std::env::temp_dir()))
2577}
2578
2579#[cfg(unix)]
2580pub(crate) fn apply_sandbox_environment(
2581    plan: &SpawnPlan,
2582    command: &mut Command,
2583    request_environment: &HashMap<String, String>,
2584) {
2585    if let Some(environment) = isolated_environment_for_plan(plan, request_environment) {
2586        // Host snapshots and native-launcher allowlists are complete child
2587        // environments. Clear the daemon environment first so loader hooks,
2588        // shell startup hooks, and cloud credentials cannot leak around them.
2589        command.env_clear().envs(environment);
2590    }
2591}
2592
2593/// Build a PTY `CommandBuilder` while enforcing the required launch plan.
2594pub(crate) fn pty_command_for_plan(
2595    plan: &SpawnPlan,
2596    program: &OsStr,
2597    args: &[OsString],
2598    task_marker: &Path,
2599    workdir: &Path,
2600    env: &HashMap<String, String>,
2601) -> Result<(CommandBuilder, Option<File>), String> {
2602    let (program, args, profile_handle) =
2603        command_argv_for_plan(plan, program, args, task_marker, None)?;
2604    let mut command = CommandBuilder::new(program);
2605    for arg in args {
2606        command.arg(arg);
2607    }
2608    command.cwd(workdir.as_os_str());
2609    if let Some(environment) = isolated_environment_for_plan(plan, env) {
2610        command.env_clear();
2611        for (key, value) in environment {
2612            command.env(key, value);
2613        }
2614    } else {
2615        // Sandbox-disabled PTYs retain the historical full inheritance and add
2616        // only request overrides.
2617        for (key, value) in env {
2618            command.env(key, value);
2619        }
2620    }
2621    Ok((command, profile_handle))
2622}
2623
2624fn command_argv_for_plan(
2625    plan: &SpawnPlan,
2626    program: &OsStr,
2627    args: &[OsString],
2628    task_marker: &Path,
2629    marker_fds: Option<(RawFd, RawFd)>,
2630) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2631    match plan.policy() {
2632        SpawnPlan::Unsandboxed | SpawnPlan::Host { .. } => {
2633            Ok((program.to_os_string(), args.to_vec(), None))
2634        }
2635        SpawnPlan::Refused { code, .. } => Err((*code).to_string()),
2636        SpawnPlan::Launcher {
2637            profile,
2638            launcher_path,
2639        } => {
2640            #[cfg(unix)]
2641            {
2642                launcher_argv(
2643                    profile,
2644                    launcher_path,
2645                    program,
2646                    args,
2647                    task_marker,
2648                    marker_fds,
2649                    plan.prepared_task(),
2650                )
2651            }
2652            #[cfg(not(unix))]
2653            {
2654                launcher_argv(
2655                    profile,
2656                    launcher_path,
2657                    program,
2658                    args,
2659                    task_marker,
2660                    marker_fds,
2661                    None,
2662                )
2663            }
2664        }
2665        #[cfg(unix)]
2666        SpawnPlan::Prepared { .. } => unreachable!("policy() unwraps prepared plans"),
2667    }
2668}
2669
2670#[cfg(unix)]
2671#[allow(clippy::too_many_arguments)]
2672fn launcher_argv(
2673    profile: &SandboxProfile,
2674    launcher_path: &Path,
2675    program: &OsStr,
2676    args: &[OsString],
2677    _task_marker: &Path,
2678    marker_fds: Option<(RawFd, RawFd)>,
2679    _prepared: Option<&PreparedTask>,
2680) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2681    let profile_json = serde_json::to_string(profile)
2682        .map_err(|error| format!("failed to serialize sandbox profile: {error}"))?;
2683    let Some((exit_fd, failure_fd)) = marker_fds else {
2684        let mut wrapped = vec![
2685            OsString::from("sandbox-launch"),
2686            OsString::from("--profile-json"),
2687            OsString::from(profile_json),
2688            OsString::from("--"),
2689            program.to_os_string(),
2690        ];
2691        wrapped.extend_from_slice(args);
2692        return Ok((launcher_path.as_os_str().to_os_string(), wrapped, None));
2693    };
2694
2695    let mut wrapped = vec![
2696        OsString::from("-c"),
2697        OsString::from(
2698            r#"launcher=$1
2699profile_json=$2
2700exit_fd=$3
2701failure_fd=$4
2702shift 4
2703"$launcher" sandbox-launch --profile-json "$profile_json" -- "$@"
2704code=$?
2705if [ "$code" -eq 78 ]; then
2706  printf "%s" sandbox_unavailable >&"$failure_fd"
2707  if [ ! -s "/dev/fd/$exit_fd" ]; then
2708    printf "%s" "$code" >&"$exit_fd"
2709  fi
2710fi
2711exit "$code""#,
2712        ),
2713        OsString::from("aft-sandbox-supervisor"),
2714        launcher_path.as_os_str().to_os_string(),
2715        OsString::from(profile_json),
2716        OsString::from(exit_fd.to_string()),
2717        OsString::from(failure_fd.to_string()),
2718        program.to_os_string(),
2719    ];
2720    wrapped.extend_from_slice(args);
2721    Ok((OsString::from("/bin/sh"), wrapped, None))
2722}
2723
2724#[cfg(not(unix))]
2725#[allow(clippy::too_many_arguments)]
2726fn launcher_argv(
2727    _profile: &SandboxProfile,
2728    _launcher_path: &Path,
2729    _program: &OsStr,
2730    _args: &[OsString],
2731    _task_marker: &Path,
2732    _marker_fds: Option<(i32, i32)>,
2733    _prepared: Option<&()>,
2734) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2735    Err("sandbox_unavailable".to_string())
2736}
2737
2738#[cfg(all(test, unix))]
2739mod tests {
2740
2741    use super::*;
2742
2743    #[test]
2744    fn host_plan_clears_inherited_environment_and_applies_snapshot() {
2745        let environment =
2746            ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
2747        let plan = SpawnPlan::Host {
2748            shell_path: PathBuf::from("/bin/sh"),
2749            environment,
2750        };
2751        let mut command = Command::new("/bin/sh");
2752        command
2753            .arg("-c")
2754            .arg("test -z \"$SHOULD_DISAPPEAR\" && printf %s \"$APPROVED\"")
2755            .env("SHOULD_DISAPPEAR", "yes");
2756        apply_sandbox_environment(&plan, &mut command, &HashMap::new());
2757        let output = command.output().unwrap();
2758        assert!(output.status.success());
2759        assert_eq!(output.stdout, b"snapshot");
2760    }
2761
2762    #[test]
2763    fn launcher_plan_clears_ambient_environment_and_applies_safe_base() {
2764        let root = tempfile::tempdir().unwrap();
2765        let project = root.path().join("project");
2766        let temp = root.path().join("temp");
2767        std::fs::create_dir_all(&project).unwrap();
2768        std::fs::create_dir_all(&temp).unwrap();
2769        let profile = SandboxProfile::build(
2770            vec![project],
2771            Vec::new(),
2772            Vec::new(),
2773            Vec::new(),
2774            Vec::new(),
2775            Vec::new(),
2776            Vec::new(),
2777            temp,
2778        )
2779        .unwrap();
2780        let expected_temp = profile.temp_dir.clone();
2781        let plan = SpawnPlan::launcher_for_test(profile, PathBuf::from("/usr/bin/true"));
2782        let request_environment = HashMap::from([
2783            ("TERM".to_string(), "aft-test-term".to_string()),
2784            ("REQUEST_SENTINEL".to_string(), "request-value".to_string()),
2785        ]);
2786        let mut command = Command::new("/usr/bin/env");
2787        command
2788            .env("LD_PRELOAD", "/untrusted/loader.so")
2789            .env("DYLD_INSERT_LIBRARIES", "/untrusted/loader.dylib")
2790            .env("BASH_ENV", "/untrusted/bash-env")
2791            .env("AWS_SECRET_ACCESS_KEY", "ambient-secret");
2792
2793        apply_sandbox_environment(&plan, &mut command, &request_environment);
2794        let output = command.output().unwrap();
2795        assert!(output.status.success());
2796        let output = String::from_utf8(output.stdout).unwrap();
2797
2798        for leaked in [
2799            "LD_PRELOAD=",
2800            "DYLD_INSERT_LIBRARIES=",
2801            "BASH_ENV=",
2802            "AWS_SECRET_ACCESS_KEY=",
2803        ] {
2804            assert!(
2805                !output.contains(leaked),
2806                "ambient variable leaked: {leaked}"
2807            );
2808        }
2809        assert!(output.contains("REQUEST_SENTINEL=request-value\n"));
2810        assert!(output.contains("TERM=aft-test-term\n"));
2811        assert!(output.contains(&format!(
2812            "PATH={}\n",
2813            crate::effective_path::effective_path().to_string_lossy()
2814        )));
2815        if let Some(home) = std::env::var_os("HOME") {
2816            assert!(output.contains(&format!("HOME={}\n", home.to_string_lossy())));
2817        }
2818        for key in ["TMPDIR", "TEMP", "TMP"] {
2819            assert!(output.contains(&format!("{key}={}\n", expected_temp.display())));
2820        }
2821    }
2822
2823    #[cfg(unix)]
2824    #[test]
2825    fn request_environment_cannot_inject_preexec_hijack_variables() {
2826        // The pre-sandbox boundary: a request-supplied environment is applied to
2827        // the OUTER launcher / `/bin/sh` supervisor, which run BEFORE Landlock or
2828        // Seatbelt is installed. A loader/shell/interpreter startup hook arriving
2829        // through the request env must be dropped, or injected code executes
2830        // outside confinement. (The sibling test covers the ambient direction;
2831        // this covers the request-supplied direction the re-audit flagged.)
2832        let project = tempfile::tempdir().unwrap();
2833        let temp = project.path().join("sandbox-temp");
2834        std::fs::create_dir(&temp).unwrap();
2835        let profile = crate::sandbox_profile::SandboxProfile::build(
2836            vec![project.path().to_path_buf()],
2837            Vec::new(),
2838            Vec::new(),
2839            Vec::new(),
2840            Vec::new(),
2841            Vec::new(),
2842            Vec::new(),
2843            temp,
2844        )
2845        .unwrap();
2846        let plan = SpawnPlan::launcher_for_test(profile, PathBuf::from("/usr/bin/true"));
2847
2848        // Every dangerous key arrives through the REQUEST environment this time.
2849        let hijacks = [
2850            ("LD_PRELOAD", "/untrusted/loader.so"),
2851            ("LD_LIBRARY_PATH", "/untrusted/lib"),
2852            ("LD_AUDIT", "/untrusted/audit.so"),
2853            ("DYLD_INSERT_LIBRARIES", "/untrusted/loader.dylib"),
2854            ("DYLD_LIBRARY_PATH", "/untrusted/dylib"),
2855            ("BASH_ENV", "/untrusted/bash-env"),
2856            ("ENV", "/untrusted/sh-env"),
2857            ("PROMPT_COMMAND", "/untrusted/cmd"),
2858            ("PS4", "evil"),
2859            ("PYTHONSTARTUP", "/untrusted/py"),
2860            ("PYTHONPATH", "/untrusted/pypath"),
2861            ("PERL5OPT", "-M/untrusted"),
2862            ("RUBYOPT", "-r/untrusted"),
2863            ("NODE_OPTIONS", "--require=/untrusted"),
2864            ("GCONV_PATH", "/untrusted/gconv"),
2865            ("LOCPATH", "/untrusted/loc"),
2866        ];
2867        let request_environment: HashMap<String, String> = hijacks
2868            .iter()
2869            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2870            .chain([("REQUEST_SENTINEL".to_string(), "kept".to_string())])
2871            .collect();
2872
2873        let mut command = Command::new("/usr/bin/env");
2874        apply_sandbox_environment(&plan, &mut command, &request_environment);
2875        let output = command.output().unwrap();
2876        assert!(output.status.success());
2877        let output = String::from_utf8(output.stdout).unwrap();
2878
2879        for (key, _) in hijacks {
2880            assert!(
2881                !output.contains(&format!("{key}=")),
2882                "request-supplied hijack var reached the pre-sandbox child: {key}\n{output}"
2883            );
2884        }
2885        // A benign request var still flows through, proving we filtered rather
2886        // than dropped the whole request environment.
2887        assert!(output.contains("REQUEST_SENTINEL=kept\n"), "{output}");
2888    }
2889
2890    #[test]
2891    fn unsandboxed_plan_preserves_inherited_and_request_environment() {
2892        let plan = SpawnPlan::Unsandboxed;
2893        let request_environment =
2894            HashMap::from([("REQUEST_SENTINEL".to_string(), "request-value".to_string())]);
2895        #[cfg(target_os = "macos")]
2896        let loader_hook = "/usr/lib/libSystem.B.dylib";
2897        #[cfg(target_os = "linux")]
2898        let loader_hook = "libc.so.6";
2899        let mut command = Command::new("/usr/bin/env");
2900        command
2901            .env("UNSANDBOXED_PARENT_SENTINEL", "raw-parent")
2902            .env("LD_PRELOAD", loader_hook)
2903            .env("DYLD_INSERT_LIBRARIES", loader_hook)
2904            .env("AWS_SECRET_ACCESS_KEY", "ambient-cloud-secret")
2905            .envs(&request_environment);
2906
2907        let before = command
2908            .get_envs()
2909            .map(|(key, value)| (key.to_os_string(), value.map(OsStr::to_os_string)))
2910            .collect::<Vec<_>>();
2911        apply_sandbox_environment(&plan, &mut command, &request_environment);
2912        let after = command
2913            .get_envs()
2914            .map(|(key, value)| (key.to_os_string(), value.map(OsStr::to_os_string)))
2915            .collect::<Vec<_>>();
2916        assert_eq!(after, before, "unsandboxed environment overrides changed");
2917        let output = command.output().unwrap();
2918        assert!(output.status.success());
2919        let output = String::from_utf8(output.stdout).unwrap();
2920        assert!(output.contains("UNSANDBOXED_PARENT_SENTINEL=raw-parent\n"));
2921        assert!(output.contains(&format!("LD_PRELOAD={loader_hook}\n")));
2922        #[cfg(target_os = "linux")]
2923        assert!(output.contains(&format!("DYLD_INSERT_LIBRARIES={loader_hook}\n")));
2924        assert!(output.contains("AWS_SECRET_ACCESS_KEY=ambient-cloud-secret\n"));
2925        assert!(output.contains("REQUEST_SENTINEL=request-value\n"));
2926    }
2927
2928    #[test]
2929    fn product_profile_is_passed_as_a_verified_buffer() {
2930        let root = tempfile::tempdir().unwrap();
2931        let project = root.path().join("project");
2932        let temp = root.path().join("temp");
2933        std::fs::create_dir_all(&project).unwrap();
2934        std::fs::create_dir_all(&temp).unwrap();
2935        let profile = SandboxProfile::build(
2936            vec![project],
2937            Vec::new(),
2938            Vec::new(),
2939            Vec::new(),
2940            Vec::new(),
2941            Vec::new(),
2942            Vec::new(),
2943            temp,
2944        )
2945        .unwrap();
2946        let (_program, args, retained) = launcher_argv(
2947            &profile,
2948            Path::new("/bin/aft"),
2949            OsStr::new("/bin/sh"),
2950            &[OsString::from("-c"), OsString::from("true")],
2951            Path::new("unused"),
2952            None,
2953            None,
2954        )
2955        .unwrap();
2956        assert_eq!(args[0], "sandbox-launch");
2957        assert_eq!(args[1], "--profile-json");
2958        assert!(serde_json::from_str::<SandboxProfile>(args[2].to_str().unwrap()).is_ok());
2959        assert!(retained.is_none());
2960        assert!(!root.path().join("sandbox-profile.json").exists());
2961    }
2962}
2963
2964#[cfg(test)]
2965mod policy_tests {
2966    use super::*;
2967
2968    fn context(project_root: PathBuf) -> AppContext {
2969        AppContext::new(
2970            Box::new(crate::parser::TreeSitterProvider::new()),
2971            crate::config::Config {
2972                project_root: Some(project_root),
2973                sandbox: crate::config::SandboxConfig {
2974                    enabled: true,
2975                    ..crate::config::SandboxConfig::default()
2976                },
2977                ..crate::config::Config::default()
2978            },
2979        )
2980    }
2981
2982    #[cfg(unix)]
2983    #[test]
2984    fn native_sandbox_predicate_controls_spawn_and_rewrite() {
2985        let project = tempfile::tempdir().unwrap();
2986        let file = project.path().join("rewrite-probe.txt");
2987        std::fs::write(&file, "sandboxed\n").unwrap();
2988        let ctx = context(project.path().to_path_buf());
2989        ctx.update_config(|config| config.experimental_bash_rewrite = true);
2990        let principal = AuthenticatedPrincipal::FirstParty;
2991        let command = format!("cat {}", file.display());
2992
2993        assert!(native_sandbox_enforced(&ctx, &principal));
2994        assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_none());
2995        let sandboxed = resolve_sandbox_spawn(
2996            &ctx,
2997            &principal,
2998            RequestedSandboxTier::Native,
2999            SandboxTaskKind::BashForeground,
3000            project.path(),
3001            None,
3002        );
3003        assert!(matches!(&sandboxed, SpawnPlan::Launcher { .. }));
3004        sandboxed.cleanup_unspawned();
3005
3006        ctx.update_config(|config| config.sandbox.enabled = false);
3007        assert!(!native_sandbox_enforced(&ctx, &principal));
3008        assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_some());
3009        assert_eq!(
3010            resolve_sandbox_spawn(
3011                &ctx,
3012                &principal,
3013                RequestedSandboxTier::Native,
3014                SandboxTaskKind::BashForeground,
3015                project.path(),
3016                None,
3017            ),
3018            SpawnPlan::Unsandboxed
3019        );
3020    }
3021
3022    #[test]
3023    fn untrusted_principal_never_enters_the_native_launcher() {
3024        let project = tempfile::tempdir().unwrap();
3025        let ctx = context(project.path().to_path_buf());
3026        let principal = AuthenticatedPrincipal::RouteBind {
3027            trust: PrincipalTrust::Untrusted,
3028            route_channel: 7,
3029            route_epoch: 1,
3030            project_root: project.path().to_path_buf(),
3031            harness: "mcp:test".to_string(),
3032            session_id: "untrusted-sandbox-test".to_string(),
3033            principal_id: Some("unverified".to_string()),
3034        };
3035
3036        let plan = resolve_sandbox_spawn(
3037            &ctx,
3038            &principal,
3039            RequestedSandboxTier::Native,
3040            SandboxTaskKind::BashForeground,
3041            project.path(),
3042            None,
3043        );
3044        // The invariant is that an untrusted principal never receives a native
3045        // Launcher plan. On Unix that surfaces as a downgrade to Unsandboxed
3046        // (the untrusted principal fails the first-party enforcement check); on
3047        // platforms without a kernel backend the enabled policy fails closed
3048        // with a platform refusal before the trust check is reached. Both honor
3049        // the invariant, so assert the exact non-Launcher outcome per platform.
3050        #[cfg(unix)]
3051        assert_eq!(plan, SpawnPlan::Unsandboxed);
3052        #[cfg(not(unix))]
3053        assert!(
3054            matches!(&plan, SpawnPlan::Refused { code, .. } if *code == "sandbox_unavailable"),
3055            "untrusted principal must never reach the native launcher; got {plan:?}"
3056        );
3057    }
3058
3059    #[cfg(unix)]
3060    fn grant_attempt(
3061        grant_id: String,
3062        project: &Path,
3063        command: &[u8],
3064        environment: ChildEnvironment,
3065    ) -> HostEscalationAttempt {
3066        HostEscalationAttempt {
3067            grant_id,
3068            command: command.to_vec(),
3069            root: project.to_path_buf(),
3070            cwd: project.to_path_buf(),
3071            shell_path: PathBuf::from("/bin/sh"),
3072            environment,
3073        }
3074    }
3075
3076    #[cfg(unix)]
3077    fn mint_test_grant(
3078        ctx: &AppContext,
3079        principal: &AuthenticatedPrincipal,
3080        project: &Path,
3081        command: &[u8],
3082        environment: &ChildEnvironment,
3083        now: Instant,
3084    ) -> String {
3085        mint_host_escalation_grant_at(
3086            ctx,
3087            principal,
3088            command,
3089            project,
3090            project,
3091            Path::new("/bin/sh"),
3092            environment,
3093            &project.join(".aft-test-storage"),
3094            "test-session",
3095            now,
3096        )
3097        .unwrap()
3098    }
3099
3100    #[cfg(unix)]
3101    fn refusal_class(plan: &SpawnPlan) -> Option<&'static str> {
3102        plan.refusal_mismatch_class()
3103    }
3104
3105    #[cfg(unix)]
3106    #[test]
3107    fn escalation_grant_binds_exact_command_and_environment() {
3108        let project = tempfile::tempdir().unwrap();
3109        let ctx = context(project.path().to_path_buf());
3110        let principal = AuthenticatedPrincipal::FirstParty;
3111        let environment = ChildEnvironment::from([
3112            (OsString::from("A"), OsString::from("one")),
3113            (OsString::from("B"), OsString::from("two")),
3114        ]);
3115
3116        for (command, retry_environment) in [
3117            (b"printf approved!".as_slice(), environment.clone()),
3118            (
3119                b"printf approved".as_slice(),
3120                ChildEnvironment::from([
3121                    (OsString::from("A"), OsString::from("changed")),
3122                    (OsString::from("B"), OsString::from("two")),
3123                ]),
3124            ),
3125        ] {
3126            let grant_id = mint_test_grant(
3127                &ctx,
3128                &principal,
3129                project.path(),
3130                b"printf approved",
3131                &environment,
3132                Instant::now(),
3133            );
3134            let attempt = grant_attempt(grant_id, project.path(), command, retry_environment);
3135            let plan = resolve_sandbox_spawn(
3136                &ctx,
3137                &principal,
3138                RequestedSandboxTier::Host,
3139                SandboxTaskKind::BashForeground,
3140                project.path(),
3141                Some(&attempt),
3142            );
3143            assert_eq!(refusal_class(&plan), Some("digest_mismatch"));
3144        }
3145    }
3146
3147    #[cfg(unix)]
3148    #[test]
3149    fn escalation_grant_is_single_use_and_expires() {
3150        let project = tempfile::tempdir().unwrap();
3151        let ctx = context(project.path().to_path_buf());
3152        let principal = AuthenticatedPrincipal::FirstParty;
3153        let environment =
3154            ChildEnvironment::from([(OsString::from("ONLY"), OsString::from("snapshot"))]);
3155        let grant_id = mint_test_grant(
3156            &ctx,
3157            &principal,
3158            project.path(),
3159            b"true",
3160            &environment,
3161            Instant::now(),
3162        );
3163        let attempt = grant_attempt(grant_id, project.path(), b"true", environment.clone());
3164        let first = resolve_sandbox_spawn(
3165            &ctx,
3166            &principal,
3167            RequestedSandboxTier::Host,
3168            SandboxTaskKind::BashForeground,
3169            project.path(),
3170            Some(&attempt),
3171        );
3172        assert!(matches!(first.policy(), SpawnPlan::Host { .. }));
3173        let second = resolve_sandbox_spawn(
3174            &ctx,
3175            &principal,
3176            RequestedSandboxTier::Host,
3177            SandboxTaskKind::BashForeground,
3178            project.path(),
3179            Some(&attempt),
3180        );
3181        assert_eq!(refusal_class(&second), Some("consumed"));
3182
3183        let expired_id = mint_test_grant(
3184            &ctx,
3185            &principal,
3186            project.path(),
3187            b"true",
3188            &environment,
3189            Instant::now() - ESCALATION_GRANT_TTL - Duration::from_millis(1),
3190        );
3191        let expired_attempt =
3192            grant_attempt(expired_id, project.path(), b"true", environment.clone());
3193        let expired = resolve_sandbox_spawn(
3194            &ctx,
3195            &principal,
3196            RequestedSandboxTier::Host,
3197            SandboxTaskKind::BashForeground,
3198            project.path(),
3199            Some(&expired_attempt),
3200        );
3201        assert_eq!(refusal_class(&expired), Some("expired"));
3202    }
3203
3204    #[cfg(unix)]
3205    #[test]
3206    fn escalation_grant_binds_principal_and_happy_path_reuses_snapshot() {
3207        let project = tempfile::tempdir().unwrap();
3208        let ctx = context(project.path().to_path_buf());
3209        let principal = AuthenticatedPrincipal::RouteBind {
3210            trust: PrincipalTrust::FirstParty,
3211            route_channel: 9,
3212            route_epoch: 2,
3213            project_root: project.path().to_path_buf(),
3214            harness: "opencode".to_string(),
3215            session_id: "session-x".to_string(),
3216            principal_id: Some("direct".to_string()),
3217        };
3218        let environment =
3219            ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
3220        let wrong_id = mint_test_grant(
3221            &ctx,
3222            &principal,
3223            project.path(),
3224            b"true",
3225            &environment,
3226            Instant::now(),
3227        );
3228        let wrong_attempt = grant_attempt(wrong_id, project.path(), b"true", environment.clone());
3229        let wrong = resolve_sandbox_spawn(
3230            &ctx,
3231            &AuthenticatedPrincipal::FirstParty,
3232            RequestedSandboxTier::Host,
3233            SandboxTaskKind::BashForeground,
3234            project.path(),
3235            Some(&wrong_attempt),
3236        );
3237        assert_eq!(refusal_class(&wrong), Some("wrong_principal"));
3238
3239        let happy_id = mint_test_grant(
3240            &ctx,
3241            &principal,
3242            project.path(),
3243            b"true",
3244            &environment,
3245            Instant::now(),
3246        );
3247        let happy_attempt = grant_attempt(happy_id, project.path(), b"true", environment.clone());
3248        let happy = resolve_sandbox_spawn(
3249            &ctx,
3250            &principal,
3251            RequestedSandboxTier::Host,
3252            SandboxTaskKind::BashForeground,
3253            project.path(),
3254            Some(&happy_attempt),
3255        );
3256        match happy.policy() {
3257            SpawnPlan::Host {
3258                shell_path,
3259                environment: actual,
3260            } => {
3261                assert_eq!(shell_path, Path::new("/bin/sh"));
3262                assert_eq!(actual, &environment);
3263            }
3264            other => panic!("expected host plan, got {other:?}"),
3265        }
3266    }
3267
3268    #[cfg(unix)]
3269    #[test]
3270    fn untrusted_host_request_refuses_without_minting_a_grant() {
3271        let project = tempfile::tempdir().unwrap();
3272        let ctx = context(project.path().to_path_buf());
3273        let principal = AuthenticatedPrincipal::RouteBind {
3274            trust: PrincipalTrust::Untrusted,
3275            route_channel: 7,
3276            route_epoch: 1,
3277            project_root: project.path().to_path_buf(),
3278            harness: "mcp:test".to_string(),
3279            session_id: "untrusted-escalation-test".to_string(),
3280            principal_id: Some("unverified".to_string()),
3281        };
3282        let plan = resolve_sandbox_spawn(
3283            &ctx,
3284            &principal,
3285            RequestedSandboxTier::Host,
3286            SandboxTaskKind::BashForeground,
3287            project.path(),
3288            None,
3289        );
3290        assert_eq!(plan.refusal_code(), Some("sandbox_escalation_denied"));
3291        assert!(ctx.escalation_grants().lock().grants.is_empty());
3292    }
3293
3294    #[cfg(unix)]
3295    fn grant_task_paths(ctx: &AppContext, grant_id: &str) -> (PathBuf, String) {
3296        let store = ctx.escalation_grants().lock();
3297        let grant = store.grants.get(grant_id).unwrap();
3298        (grant.session_dir.clone(), grant.task_id.clone())
3299    }
3300
3301    #[cfg(unix)]
3302    #[test]
3303    fn escalated_payload_path_race_is_refused_and_burns_the_grant() {
3304        use std::os::unix::fs::symlink;
3305
3306        let project = tempfile::tempdir().unwrap();
3307        let ctx = context(project.path().to_path_buf());
3308        let principal = AuthenticatedPrincipal::FirstParty;
3309        let environment = ChildEnvironment::new();
3310        let grant_id = mint_test_grant(
3311            &ctx,
3312            &principal,
3313            project.path(),
3314            b"true",
3315            &environment,
3316            Instant::now(),
3317        );
3318        let (session_dir, task_id) = grant_task_paths(&ctx, &grant_id);
3319        let command_path = session_dir
3320            .join(&task_id)
3321            .join("control")
3322            .join(crate::bash_background::persistence::COMMAND_FILE);
3323        let victim = project.path().join("victim");
3324        std::fs::write(&victim, b"victim-bytes").unwrap();
3325        std::fs::remove_file(&command_path).unwrap();
3326        symlink(&victim, &command_path).unwrap();
3327
3328        let attempt = grant_attempt(grant_id.clone(), project.path(), b"true", environment);
3329        let refused = resolve_sandbox_spawn(
3330            &ctx,
3331            &principal,
3332            RequestedSandboxTier::Host,
3333            SandboxTaskKind::BashForeground,
3334            project.path(),
3335            Some(&attempt),
3336        );
3337        assert_eq!(refusal_class(&refused), Some("digest_mismatch"));
3338        assert_eq!(std::fs::read(&victim).unwrap(), b"victim-bytes");
3339        let consumed = resolve_sandbox_spawn(
3340            &ctx,
3341            &principal,
3342            RequestedSandboxTier::Host,
3343            SandboxTaskKind::BashForeground,
3344            project.path(),
3345            Some(&attempt),
3346        );
3347        assert_eq!(refusal_class(&consumed), Some("consumed"));
3348    }
3349
3350    #[cfg(unix)]
3351    #[test]
3352    fn verified_host_payload_executes_verified_buffers_after_inode_mutation() {
3353        use std::fs::OpenOptions;
3354        use std::os::fd::AsRawFd;
3355        use std::os::unix::fs::OpenOptionsExt;
3356
3357        let project = tempfile::tempdir().unwrap();
3358        let ctx = context(project.path().to_path_buf());
3359        let principal = AuthenticatedPrincipal::FirstParty;
3360        let environment = ChildEnvironment::new();
3361        let grant_id = mint_test_grant(
3362            &ctx,
3363            &principal,
3364            project.path(),
3365            b"true",
3366            &environment,
3367            Instant::now(),
3368        );
3369        let attempt = grant_attempt(grant_id, project.path(), b"true", environment);
3370        let plan = resolve_sandbox_spawn(
3371            &ctx,
3372            &principal,
3373            RequestedSandboxTier::Host,
3374            SandboxTaskKind::BashForeground,
3375            project.path(),
3376            Some(&attempt),
3377        );
3378        let prepared = plan.prepared_task().expect("verified prepared task");
3379        let command_path = prepared
3380            .paths()
3381            .control_dir
3382            .join(crate::bash_background::persistence::COMMAND_FILE);
3383        let victim = project.path().join("victim");
3384        std::fs::write(&victim, b"victim-bytes").unwrap();
3385        let payload = prepared.invocation().unwrap();
3386        // Mutate the same inode after verification. Execution must use the
3387        // verified in-memory buffers rather than rereading the held file.
3388        std::fs::write(
3389            &command_path,
3390            format!("printf hacked > {}", victim.display()),
3391        )
3392        .unwrap();
3393        let exit_path = project.path().join("exit");
3394        let exit = OpenOptions::new()
3395            .read(true)
3396            .write(true)
3397            .create_new(true)
3398            .mode(0o600)
3399            .open(&exit_path)
3400            .unwrap();
3401        crate::bash_background::persistence::set_close_on_exec(exit.as_raw_fd(), false).unwrap();
3402        // Mirror production (apply_marker_fd_allowlist): the real marker fd is
3403        // dup2'd onto the low, single-digit CHILD_EXIT_FD before exec, and the
3404        // wrapper is handed that literal. POSIX sh (dash) only parses a
3405        // single-digit `>&N` redirect target, so passing a raw multi-digit fd
3406        // here would diverge from production and fail under dash with
3407        // "Bad fd number" (production never hits this: it always remaps to 3).
3408        let raw_exit_fd = exit.as_raw_fd();
3409        let exit_fd = CHILD_EXIT_FD.to_string();
3410        let mut command = Command::new("/bin/sh");
3411        command.args([
3412            OsStr::new("-c"),
3413            payload.wrapper_text.as_os_str(),
3414            OsStr::new("aft-payload-wrapper"),
3415            OsStr::new("/bin/sh"),
3416            payload.command_text.as_os_str(),
3417            OsStr::new(&exit_fd),
3418        ]);
3419        {
3420            use std::os::unix::process::CommandExt;
3421            unsafe {
3422                command.pre_exec(move || {
3423                    if raw_exit_fd != CHILD_EXIT_FD && libc::dup2(raw_exit_fd, CHILD_EXIT_FD) < 0 {
3424                        return Err(std::io::Error::last_os_error());
3425                    }
3426                    Ok(())
3427                });
3428            }
3429        }
3430        let status = command.status().unwrap();
3431        assert!(status.success());
3432        assert_eq!(std::fs::read(&victim).unwrap(), b"victim-bytes");
3433    }
3434
3435    #[cfg(unix)]
3436    #[test]
3437    fn approval_spawn_drift_matrix_refuses_every_bound_field() {
3438        let project = tempfile::tempdir().unwrap();
3439        let ctx = context(project.path().to_path_buf());
3440        let principal = AuthenticatedPrincipal::FirstParty;
3441        let approved = ChildEnvironment::from([(OsString::from("A"), OsString::from("one"))]);
3442        for drift in [
3443            "command",
3444            "newline",
3445            "encoding",
3446            "cwd",
3447            "root",
3448            "shell",
3449            "environment",
3450            "environment_file_encoding",
3451            "wrapper_template",
3452        ] {
3453            let grant_id = mint_test_grant(
3454                &ctx,
3455                &principal,
3456                project.path(),
3457                b"printf approved",
3458                &approved,
3459                Instant::now(),
3460            );
3461            let mut attempt = grant_attempt(
3462                grant_id,
3463                project.path(),
3464                b"printf approved",
3465                approved.clone(),
3466            );
3467            match drift {
3468                "command" => attempt.command = b"printf changed".to_vec(),
3469                "newline" => attempt.command.push(b'\n'),
3470                "encoding" => attempt.command.push(0xff),
3471                "cwd" => attempt.cwd = project.path().join("changed-cwd"),
3472                "root" => attempt.root = project.path().join("changed-root"),
3473                "shell" => attempt.shell_path = PathBuf::from("/bin/bash"),
3474                "environment" => {
3475                    attempt
3476                        .environment
3477                        .insert(OsString::from("A"), OsString::from("two"));
3478                }
3479                "environment_file_encoding" | "wrapper_template" => {
3480                    let (session_dir, task_id) = grant_task_paths(&ctx, &attempt.grant_id);
3481                    let name = if drift == "wrapper_template" {
3482                        crate::bash_background::persistence::WRAPPER_FILE
3483                    } else {
3484                        crate::bash_background::persistence::ENVIRONMENT_FILE
3485                    };
3486                    std::fs::write(
3487                        session_dir.join(task_id).join("control").join(name),
3488                        b"drift",
3489                    )
3490                    .unwrap();
3491                }
3492                _ => unreachable!(),
3493            }
3494            let refused = resolve_sandbox_spawn(
3495                &ctx,
3496                &principal,
3497                RequestedSandboxTier::Host,
3498                SandboxTaskKind::BashForeground,
3499                project.path(),
3500                Some(&attempt),
3501            );
3502            assert_eq!(refusal_class(&refused), Some("digest_mismatch"), "{drift}");
3503        }
3504    }
3505
3506    #[cfg(unix)]
3507    #[test]
3508    fn payload_read_grant_seam_exposes_only_exact_control_objects() {
3509        let storage = tempfile::tempdir().unwrap();
3510        let project = tempfile::tempdir().unwrap();
3511        let principal = AuthenticatedPrincipal::FirstParty;
3512        let environment = ChildEnvironment::from([(OsString::from("SAFE"), OsString::from("yes"))]);
3513        let layout =
3514            crate::bash_background::persistence::allocate_task_layout(storage.path(), "session")
3515                .unwrap();
3516        let task = prepare_task_payload(
3517            &layout,
3518            b"true",
3519            project.path(),
3520            project.path(),
3521            &principal,
3522            Path::new("/bin/sh"),
3523            &environment,
3524        )
3525        .unwrap();
3526        let plan = SpawnPlan::Unsandboxed.with_prepared_task(task.clone());
3527        let grants = plan.payload_read_grants();
3528        assert_eq!(grants.len(), 3);
3529        assert_eq!(grants, task.payload_read_grants());
3530        assert!(grants
3531            .iter()
3532            .all(|path| path.parent() == Some(task.paths().control_dir.as_path())));
3533        assert!(!grants.contains(&task.paths().manifest));
3534        let sandbox_temp = storage.path().join("sandbox-temp");
3535        std::fs::create_dir_all(&sandbox_temp).unwrap();
3536        let b2_profile = SandboxProfile::build(
3537            vec![project.path().to_path_buf()],
3538            Vec::new(),
3539            Vec::new(),
3540            Vec::new(),
3541            vec![task.paths().control_dir.clone()],
3542            Vec::new(),
3543            Vec::new(),
3544            sandbox_temp,
3545        )
3546        .unwrap();
3547        assert!(b2_profile
3548            .read_deny
3549            .contains(&std::fs::canonicalize(&task.paths().control_dir).unwrap()));
3550        assert!(grants.iter().all(|path| {
3551            path.parent() == Some(task.paths().control_dir.as_path())
3552                && path != &task.paths().manifest
3553        }));
3554
3555        #[cfg(target_os = "linux")]
3556        {
3557            let refused = SpawnPlan::launcher_for_test(b2_profile, PathBuf::from("/usr/bin/false"))
3558                .with_prepared_task(task.clone());
3559            assert_eq!(refused.refusal_code(), Some("sandbox_unavailable"));
3560            assert!(refused
3561                .refusal_message()
3562                .is_some_and(|message| message.contains("mandatory read root")));
3563
3564            let allowed_temp = storage.path().join("allowed-sandbox-temp");
3565            std::fs::create_dir_all(&allowed_temp).unwrap();
3566            let allowed_profile = SandboxProfile::build(
3567                vec![project.path().to_path_buf()],
3568                Vec::new(),
3569                Vec::new(),
3570                vec![project.path().to_path_buf()],
3571                Vec::new(),
3572                Vec::new(),
3573                Vec::new(),
3574                allowed_temp,
3575            )
3576            .unwrap();
3577            let prepared =
3578                SpawnPlan::launcher_for_test(allowed_profile, PathBuf::from("/usr/bin/true"))
3579                    .with_prepared_task(task.clone());
3580            let profile = match prepared {
3581                SpawnPlan::Prepared { plan, .. } => match *plan {
3582                    SpawnPlan::Launcher { profile, .. } => profile,
3583                    other => panic!("expected launcher plan, got {other:?}"),
3584                },
3585                other => panic!("expected prepared plan, got {other:?}"),
3586            };
3587            let canonical_payloads = grants
3588                .iter()
3589                .map(|path| path.canonicalize().unwrap())
3590                .collect::<Vec<_>>();
3591            assert!(canonical_payloads
3592                .iter()
3593                .all(|path| profile.read_allow.contains(path)));
3594            assert!(profile.read_allow.iter().all(|path| {
3595                !path.starts_with(&task.paths().control_dir) || canonical_payloads.contains(path)
3596            }));
3597            validate_final_read_rules(&profile.read_allow, &profile.read_deny).unwrap();
3598        }
3599
3600        assert_eq!(task.environment(), &environment);
3601    }
3602
3603    #[cfg(unix)]
3604    #[test]
3605    fn writable_roots_refuse_both_session_store_overlap_directions() {
3606        fn profile(base: &Path, write_root: PathBuf) -> SandboxProfile {
3607            let project = base.join("project");
3608            let home = base.join("home");
3609            let temp = base.join("temp");
3610            for path in [&project, &home, &temp, &write_root] {
3611                std::fs::create_dir_all(path).unwrap();
3612            }
3613            SandboxProfile::build(
3614                vec![project, write_root],
3615                Vec::new(),
3616                Vec::new(),
3617                Vec::new(),
3618                Vec::new(),
3619                Vec::new(),
3620                Vec::new(),
3621                temp,
3622            )
3623            .unwrap()
3624        }
3625
3626        let base = tempfile::tempdir().unwrap();
3627        let session = base.path().join("store/session");
3628        let io = session.join("bash-0000000000000001/io");
3629        let control = session.join("bash-0000000000000001/control");
3630        std::fs::create_dir_all(&io).unwrap();
3631        std::fs::create_dir_all(&control).unwrap();
3632        let canonical_session = std::fs::canonicalize(&session).unwrap();
3633        let canonical_io = std::fs::canonicalize(&io).unwrap();
3634
3635        let ancestor = profile(base.path(), base.path().join("store"));
3636        assert!(refuse_store_overlap(&ancestor, &canonical_session, &canonical_io).is_err());
3637        let descendant = profile(base.path(), control);
3638        assert!(refuse_store_overlap(&descendant, &canonical_session, &canonical_io).is_err());
3639        let allowed = profile(base.path(), io);
3640        assert!(refuse_store_overlap(&allowed, &canonical_session, &canonical_io).is_ok());
3641    }
3642
3643    #[cfg(windows)]
3644    #[test]
3645    fn enabled_host_request_is_refused_on_windows_without_a_grant() {
3646        let project = tempfile::tempdir().unwrap();
3647        let ctx = context(project.path().to_path_buf());
3648        let plan = resolve_sandbox_spawn(
3649            &ctx,
3650            &AuthenticatedPrincipal::FirstParty,
3651            RequestedSandboxTier::Host,
3652            SandboxTaskKind::BashForeground,
3653            project.path(),
3654            None,
3655        );
3656        assert_eq!(plan.refusal_code(), Some("sandbox_unavailable"));
3657        assert_eq!(
3658            plan.refusal_message(),
3659            Some(
3660                "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
3661            )
3662        );
3663    }
3664
3665    #[cfg(windows)]
3666    #[test]
3667    fn enabled_native_tier_is_refused_on_windows() {
3668        let project = tempfile::tempdir().unwrap();
3669        let ctx = context(project.path().to_path_buf());
3670        let plan = resolve_sandbox_spawn(
3671            &ctx,
3672            &AuthenticatedPrincipal::FirstParty,
3673            RequestedSandboxTier::Native,
3674            SandboxTaskKind::BashForeground,
3675            project.path(),
3676            None,
3677        );
3678        assert_eq!(plan.refusal_code(), Some("sandbox_unavailable"));
3679        assert_eq!(
3680            plan.refusal_message(),
3681            Some(
3682                "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
3683            )
3684        );
3685    }
3686}
3687
3688#[cfg(test)]
3689mod read_allow_tests {
3690    use super::*;
3691
3692    #[derive(Default)]
3693    struct FakeLister {
3694        entries: BTreeMap<PathBuf, Result<Vec<ListedReadChild>, String>>,
3695    }
3696
3697    impl FakeLister {
3698        fn directory(mut self, parent: &str, children: &[(&str, bool)]) -> Self {
3699            let parent = PathBuf::from(parent);
3700            self.entries.insert(
3701                parent.clone(),
3702                Ok(children
3703                    .iter()
3704                    .map(|(name, is_dir)| ListedReadChild {
3705                        path: parent.join(name),
3706                        is_dir: *is_dir,
3707                    })
3708                    .collect()),
3709            );
3710            self
3711        }
3712
3713        fn failure(mut self, parent: &str, message: &str) -> Self {
3714            self.entries
3715                .insert(PathBuf::from(parent), Err(message.to_string()));
3716            self
3717        }
3718    }
3719
3720    impl ReadDirectoryLister for FakeLister {
3721        fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String> {
3722            self.entries
3723                .remove(parent)
3724                .unwrap_or_else(|| Err(format!("unexpected enumeration of {}", parent.display())))
3725        }
3726    }
3727
3728    fn grant(path: &str, force_children: bool, mandatory: bool) -> IntendedReadGrant {
3729        IntendedReadGrant {
3730            path: PathBuf::from(path),
3731            force_children,
3732            mandatory,
3733        }
3734    }
3735
3736    #[test]
3737    fn read_grants_split_home_across_all_deny_chains() {
3738        let mut lister = FakeLister::default()
3739            .directory(
3740                "/home/alice",
3741                &[
3742                    (".ssh", true),
3743                    (".config", true),
3744                    ("work", true),
3745                    ("notes", false),
3746                ],
3747            )
3748            .directory(
3749                "/home/alice/.config",
3750                &[("gcloud", true), ("cortexkit", true), ("editor", true)],
3751            )
3752            .directory("/home/alice/work", &[("private", true), ("src", true)]);
3753        let denies = [
3754            "/home/alice/.ssh",
3755            "/home/alice/.config/gcloud",
3756            "/home/alice/.config/cortexkit",
3757            "/home/alice/work/private",
3758        ]
3759        .map(PathBuf::from);
3760
3761        let emitted = split_read_grants(&[grant("/home/alice", true, false)], &denies, &mut lister)
3762            .expect("split HOME grants");
3763
3764        assert_eq!(
3765            emitted,
3766            [
3767                "/home/alice/.config/editor",
3768                "/home/alice/notes",
3769                "/home/alice/work/src",
3770            ]
3771            .map(PathBuf::from)
3772        );
3773    }
3774
3775    #[test]
3776    fn secure_enumeration_omits_home_child_symlinks() {
3777        let mut lister = FakeLister::default()
3778            .directory("/home/alice", &[("ordinary", true), ("plain-file", false)]);
3779        let emitted = split_read_grants(
3780            &[grant("/home/alice", true, false)],
3781            &[PathBuf::from("/home/alice/.ssh")],
3782            &mut lister,
3783        )
3784        .expect("split HOME grants");
3785
3786        assert_eq!(
3787            emitted,
3788            ["/home/alice/ordinary", "/home/alice/plain-file"].map(PathBuf::from)
3789        );
3790        assert!(!emitted.iter().any(|path| path.ends_with("secret-link")));
3791    }
3792
3793    #[test]
3794    fn enumeration_race_refuses_instead_of_weakening_the_floor() {
3795        let mut lister = FakeLister::default().failure("/home/alice", "entry disappeared");
3796        let error = split_read_grants(
3797            &[grant("/home/alice", true, false)],
3798            &[PathBuf::from("/home/alice/.ssh")],
3799            &mut lister,
3800        )
3801        .expect_err("racing enumeration must fail closed");
3802
3803        assert!(error.contains("cannot split read root /home/alice"));
3804        assert!(error.contains("entry disappeared"));
3805    }
3806
3807    #[test]
3808    fn mandatory_floor_rejects_equal_containing_and_nested_writable_roots() {
3809        let floor = vec![PathBuf::from("/home/alice/.ssh")];
3810        for writable in [
3811            Path::new("/home/alice/.ssh"),
3812            Path::new("/home/alice"),
3813            Path::new("/home/alice/.ssh/cache"),
3814        ] {
3815            let error = validate_mandatory_floor_overlap([writable], &floor)
3816                .expect_err("mandatory floor overlap must refuse");
3817            assert!(error.contains("overlaps mandatory secret floor"));
3818        }
3819        validate_mandatory_floor_overlap([Path::new("/home/alice/project")], &floor)
3820            .expect("disjoint writable root");
3821    }
3822
3823    #[test]
3824    fn ordinary_read_deny_under_writable_root_is_split_not_refused() {
3825        let mut lister =
3826            FakeLister::default().directory("/project", &[("private", true), ("src", true)]);
3827        let writable_root = PathBuf::from("/project");
3828        let emitted = split_read_grants(
3829            &[IntendedReadGrant {
3830                path: writable_root.clone(),
3831                force_children: false,
3832                mandatory: false,
3833            }],
3834            &[PathBuf::from("/project/private")],
3835            &mut lister,
3836        )
3837        .expect("ordinary deny should be expressible");
3838
3839        assert_eq!(emitted, vec![PathBuf::from("/project/src")]);
3840        assert_eq!(writable_root, PathBuf::from("/project"));
3841    }
3842
3843    #[test]
3844    fn static_var_grant_splits_when_home_is_beneath_it() {
3845        let mut lister = FakeLister::default()
3846            .directory("/var", &[("home", true), ("log", true)])
3847            .directory("/var/home", &[("alice", true)])
3848            .directory("/var/home/alice", &[(".ssh", true), ("work", true)]);
3849        let emitted = split_read_grants(
3850            &[grant("/var", true, true)],
3851            &[PathBuf::from("/var/home/alice/.ssh")],
3852            &mut lister,
3853        )
3854        .expect("split /var around HOME floor");
3855
3856        assert_eq!(
3857            emitted,
3858            ["/var/home/alice/work", "/var/log"].map(PathBuf::from)
3859        );
3860    }
3861
3862    #[test]
3863    fn run_sensitive_directories_are_removed_by_canonical_deny_chain() {
3864        let mut lister = FakeLister::default().directory(
3865            "/run",
3866            &[
3867                ("lock", true),
3868                ("user", true),
3869                ("credentials", true),
3870                ("secrets", true),
3871            ],
3872        );
3873        let emitted = split_read_grants(
3874            &[grant("/run", false, true)],
3875            &[
3876                PathBuf::from("/run/user"),
3877                PathBuf::from("/run/credentials"),
3878                PathBuf::from("/run/secrets"),
3879            ],
3880            &mut lister,
3881        )
3882        .expect("split /run");
3883
3884        assert_eq!(emitted, vec![PathBuf::from("/run/lock")]);
3885        assert!(!emitted.iter().any(|path| {
3886            path == Path::new("/run/credentials") || path == Path::new("/run/secrets")
3887        }));
3888    }
3889
3890    #[test]
3891    fn final_validation_rejects_every_overlap_direction() {
3892        let deny = vec![PathBuf::from("/home/alice/.ssh")];
3893        for grant in [
3894            PathBuf::from("/home/alice"),
3895            PathBuf::from("/home/alice/.ssh"),
3896            PathBuf::from("/home/alice/.ssh/key"),
3897        ] {
3898            assert!(validate_final_read_rules(&[grant], &deny).is_err());
3899        }
3900        validate_final_read_rules(&[PathBuf::from("/home/alice/work")], &deny)
3901            .expect("disjoint final grant");
3902    }
3903
3904    #[test]
3905    fn grant_beneath_ordinary_deny_is_dropped_but_mandatory_grant_refuses() {
3906        let deny = vec![PathBuf::from("/restricted")];
3907        let mut lister = FakeLister::default();
3908        let emitted = split_read_grants(
3909            &[grant("/restricted/project", false, false)],
3910            &deny,
3911            &mut lister,
3912        )
3913        .expect("ordinary grant is optional");
3914        assert!(emitted.is_empty());
3915
3916        let error = split_read_grants(
3917            &[grant("/restricted/system", false, true)],
3918            &deny,
3919            &mut lister,
3920        )
3921        .expect_err("mandatory grant under deny must refuse");
3922        assert!(error.contains("mandatory read root"));
3923    }
3924
3925    #[cfg(unix)]
3926    #[test]
3927    fn linked_worktree_resolves_common_git_dir_and_shared_hooks() {
3928        let fixture = tempfile::tempdir().expect("fixture");
3929        let main = fixture.path().join("main");
3930        let worktree = fixture.path().join("linked");
3931        std::fs::create_dir(&main).expect("main repository");
3932        assert!(Command::new("git")
3933            .args(["init", "-q"])
3934            .current_dir(&main)
3935            .status()
3936            .expect("git init")
3937            .success());
3938        std::fs::write(main.join("tracked"), b"tracked").expect("tracked file");
3939        assert!(Command::new("git")
3940            .args(["add", "tracked"])
3941            .current_dir(&main)
3942            .status()
3943            .expect("git add")
3944            .success());
3945        assert!(Command::new("git")
3946            .args([
3947                "-c",
3948                "user.name=AFT Test",
3949                "-c",
3950                "user.email=aft@example.invalid",
3951                "commit",
3952                "-qm",
3953                "initial",
3954            ])
3955            .current_dir(&main)
3956            .status()
3957            .expect("git commit")
3958            .success());
3959        assert!(Command::new("git")
3960            .args(["worktree", "add", "-q"])
3961            .arg(&worktree)
3962            .arg("HEAD")
3963            .current_dir(&main)
3964            .status()
3965            .expect("git worktree add")
3966            .success());
3967
3968        let policy = resolve_git_policy(&worktree).expect("resolve linked worktree policy");
3969        let common = main.join(".git").canonicalize().expect("common git dir");
3970        assert_eq!(policy.hooks, vec![common.join("hooks")]);
3971        #[cfg(target_os = "linux")]
3972        assert!(policy.read_roots.contains(&common));
3973    }
3974
3975    #[cfg(unix)]
3976    #[test]
3977    fn configured_hooks_path_is_resolved_to_its_effective_location() {
3978        let fixture = tempfile::tempdir().expect("fixture");
3979        let project = fixture.path().join("project");
3980        std::fs::create_dir(&project).expect("project");
3981        assert!(Command::new("git")
3982            .args(["init", "-q"])
3983            .current_dir(&project)
3984            .status()
3985            .expect("git init")
3986            .success());
3987        assert!(Command::new("git")
3988            .args(["config", "core.hooksPath", "custom-hooks"])
3989            .current_dir(&project)
3990            .status()
3991            .expect("git config")
3992            .success());
3993
3994        let policy = resolve_git_policy(&project).expect("resolve configured hooks path");
3995        assert_eq!(
3996            policy.hooks,
3997            vec![project
3998                .canonicalize()
3999                .expect("canonical project")
4000                .join("custom-hooks")]
4001        );
4002    }
4003}