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