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    use std::os::unix::process::CommandExt;
2477
2478    let mut command = crate::effective_path::new_command(program);
2479    command.args(args).process_group(0);
2480    Ok((command, profile_handle))
2481}
2482
2483fn isolated_environment_for_plan(
2484    plan: &SpawnPlan,
2485    request_environment: &HashMap<String, String>,
2486) -> Option<ChildEnvironment> {
2487    #[cfg(unix)]
2488    if !matches!(plan.policy(), SpawnPlan::Unsandboxed) {
2489        if let Some(task) = plan.prepared_task() {
2490            return Some(task.environment().clone());
2491        }
2492    }
2493    match plan.policy() {
2494        SpawnPlan::Host { environment, .. } => Some(environment.clone()),
2495        SpawnPlan::Launcher { profile, .. } => Some(sandboxed_child_environment(
2496            request_environment,
2497            &profile.temp_dir,
2498        )),
2499        SpawnPlan::Unsandboxed | SpawnPlan::Refused { .. } => None,
2500        #[cfg(unix)]
2501        SpawnPlan::Prepared { .. } => unreachable!("policy() unwraps prepared plans"),
2502    }
2503}
2504
2505fn sandboxed_child_environment(
2506    request_environment: &HashMap<String, String>,
2507    temp_dir: &Path,
2508) -> ChildEnvironment {
2509    let mut environment = std::env::vars_os()
2510        .filter(|(key, _)| sandbox_base_environment_key(key))
2511        .collect::<ChildEnvironment>();
2512    // PATH must be AFT's enriched value rather than the daemon's original
2513    // value, which can omit package-manager and user tool locations.
2514    environment.insert(
2515        OsString::from("PATH"),
2516        crate::effective_path::effective_path().to_os_string(),
2517    );
2518    for (key, value) in request_environment {
2519        // A request-supplied environment reaches the OUTER launcher / `/bin/sh`
2520        // supervisor, which exec before Landlock/Seatbelt is installed. A
2521        // dynamic-loader or shell/interpreter startup hook here would execute
2522        // code OUTSIDE confinement, so those keys are dropped even though they
2523        // arrived through the (otherwise honored) request environment.
2524        if is_preexec_hijack_env_key(key.as_str()) {
2525            continue;
2526        }
2527        environment.insert(OsString::from(key), OsString::from(value));
2528    }
2529    for key in ["TMPDIR", "TEMP", "TMP"] {
2530        environment.insert(OsString::from(key), temp_dir.as_os_str().to_os_string());
2531    }
2532    environment
2533}
2534
2535fn sandbox_base_environment_key(key: &OsStr) -> bool {
2536    key.to_str().is_some_and(|key| {
2537        matches!(key, "HOME" | "USER" | "LOGNAME" | "SHELL" | "TERM" | "LANG")
2538            || key.starts_with("LC_")
2539    })
2540}
2541
2542/// Environment keys that make a process load a library or run code during its
2543/// own startup — before a native sandbox is installed in the launcher chain.
2544/// These are refused from the request environment regardless of value so an
2545/// injected `LD_PRELOAD` / `DYLD_INSERT_LIBRARIES` / `BASH_ENV` cannot execute
2546/// outside confinement. Matching is case-sensitive; POSIX environment names are
2547/// case-sensitive and the loaders only honor the exact upper-case spellings.
2548fn is_preexec_hijack_env_key(key: &str) -> bool {
2549    // Dynamic-loader families (glibc/musl `LD_*`, macdyld `DYLD_*`): the whole
2550    // prefix executes/loads at exec time, so block the family, not a fixed set.
2551    if key.starts_with("LD_") || key.starts_with("DYLD_") {
2552        return true;
2553    }
2554    matches!(
2555        key,
2556        // POSIX/bash shell startup + trace hooks (the supervisor is `/bin/sh`).
2557        "BASH_ENV"
2558            | "ENV"
2559            | "SHELLOPTS"
2560            | "BASHOPTS"
2561            | "PROMPT_COMMAND"
2562            | "PS4"
2563            | "IFS"
2564            // Interpreter auto-run / library-injection hooks.
2565            | "PYTHONSTARTUP"
2566            | "PYTHONPATH"
2567            | "PYTHONHOME"
2568            | "PERL5OPT"
2569            | "PERL5LIB"
2570            | "PERLLIB"
2571            | "PERL5DB"
2572            | "RUBYOPT"
2573            | "RUBYLIB"
2574            | "NODE_OPTIONS"
2575            // glibc auxiliary loader hooks.
2576            | "GCONV_PATH"
2577            | "LOCPATH"
2578            | "NLSPATH"
2579            | "HOSTALIASES"
2580            | "RESOLV_HOST_CONF"
2581    )
2582}
2583
2584#[cfg(unix)]
2585pub(crate) fn approved_environment_for_plan(
2586    plan: &SpawnPlan,
2587    request_environment: &HashMap<String, String>,
2588) -> ChildEnvironment {
2589    isolated_environment_for_plan(plan, request_environment)
2590        .unwrap_or_else(|| approved_payload_environment(request_environment, &std::env::temp_dir()))
2591}
2592
2593#[cfg(unix)]
2594pub(crate) fn apply_sandbox_environment(
2595    plan: &SpawnPlan,
2596    command: &mut Command,
2597    request_environment: &HashMap<String, String>,
2598) {
2599    if let Some(environment) = isolated_environment_for_plan(plan, request_environment) {
2600        // Host snapshots and native-launcher allowlists are complete child
2601        // environments. Clear the daemon environment first so loader hooks,
2602        // shell startup hooks, and cloud credentials cannot leak around them.
2603        command.env_clear().envs(environment);
2604    }
2605}
2606
2607/// Build a PTY `CommandBuilder` while enforcing the required launch plan.
2608pub(crate) fn pty_command_for_plan(
2609    plan: &SpawnPlan,
2610    program: &OsStr,
2611    args: &[OsString],
2612    task_marker: &Path,
2613    workdir: &Path,
2614    env: &HashMap<String, String>,
2615) -> Result<(CommandBuilder, Option<File>), String> {
2616    let (program, args, profile_handle) =
2617        command_argv_for_plan(plan, program, args, task_marker, None)?;
2618    let mut command = CommandBuilder::new(program);
2619    for arg in args {
2620        command.arg(arg);
2621    }
2622    command.cwd(workdir.as_os_str());
2623    if let Some(environment) = isolated_environment_for_plan(plan, env) {
2624        command.env_clear();
2625        for (key, value) in environment {
2626            command.env(key, value);
2627        }
2628    } else {
2629        // Sandbox-disabled PTYs retain the historical full inheritance and add
2630        // only request overrides.
2631        for (key, value) in env {
2632            command.env(key, value);
2633        }
2634    }
2635    Ok((command, profile_handle))
2636}
2637
2638fn command_argv_for_plan(
2639    plan: &SpawnPlan,
2640    program: &OsStr,
2641    args: &[OsString],
2642    task_marker: &Path,
2643    marker_fds: Option<(RawFd, RawFd)>,
2644) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2645    match plan.policy() {
2646        SpawnPlan::Unsandboxed | SpawnPlan::Host { .. } => {
2647            Ok((program.to_os_string(), args.to_vec(), None))
2648        }
2649        SpawnPlan::Refused { code, .. } => Err((*code).to_string()),
2650        SpawnPlan::Launcher {
2651            profile,
2652            launcher_path,
2653        } => {
2654            #[cfg(unix)]
2655            {
2656                launcher_argv(
2657                    profile,
2658                    launcher_path,
2659                    program,
2660                    args,
2661                    task_marker,
2662                    marker_fds,
2663                    plan.prepared_task(),
2664                )
2665            }
2666            #[cfg(not(unix))]
2667            {
2668                launcher_argv(
2669                    profile,
2670                    launcher_path,
2671                    program,
2672                    args,
2673                    task_marker,
2674                    marker_fds,
2675                    None,
2676                )
2677            }
2678        }
2679        #[cfg(unix)]
2680        SpawnPlan::Prepared { .. } => unreachable!("policy() unwraps prepared plans"),
2681    }
2682}
2683
2684#[cfg(unix)]
2685#[allow(clippy::too_many_arguments)]
2686fn launcher_argv(
2687    profile: &SandboxProfile,
2688    launcher_path: &Path,
2689    program: &OsStr,
2690    args: &[OsString],
2691    _task_marker: &Path,
2692    marker_fds: Option<(RawFd, RawFd)>,
2693    _prepared: Option<&PreparedTask>,
2694) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2695    let profile_json = serde_json::to_string(profile)
2696        .map_err(|error| format!("failed to serialize sandbox profile: {error}"))?;
2697    let Some((exit_fd, failure_fd)) = marker_fds else {
2698        let mut wrapped = vec![
2699            OsString::from("sandbox-launch"),
2700            OsString::from("--profile-json"),
2701            OsString::from(profile_json),
2702            OsString::from("--"),
2703            program.to_os_string(),
2704        ];
2705        wrapped.extend_from_slice(args);
2706        return Ok((launcher_path.as_os_str().to_os_string(), wrapped, None));
2707    };
2708
2709    let mut wrapped = vec![
2710        OsString::from("-c"),
2711        OsString::from(
2712            r#"launcher=$1
2713profile_json=$2
2714exit_fd=$3
2715failure_fd=$4
2716shift 4
2717"$launcher" sandbox-launch --profile-json "$profile_json" -- "$@"
2718code=$?
2719if [ "$code" -eq 78 ]; then
2720  printf "%s" sandbox_unavailable >&"$failure_fd"
2721  if [ ! -s "/dev/fd/$exit_fd" ]; then
2722    printf "%s" "$code" >&"$exit_fd"
2723  fi
2724fi
2725exit "$code""#,
2726        ),
2727        OsString::from("aft-sandbox-supervisor"),
2728        launcher_path.as_os_str().to_os_string(),
2729        OsString::from(profile_json),
2730        OsString::from(exit_fd.to_string()),
2731        OsString::from(failure_fd.to_string()),
2732        program.to_os_string(),
2733    ];
2734    wrapped.extend_from_slice(args);
2735    Ok((OsString::from("/bin/sh"), wrapped, None))
2736}
2737
2738#[cfg(not(unix))]
2739#[allow(clippy::too_many_arguments)]
2740fn launcher_argv(
2741    _profile: &SandboxProfile,
2742    _launcher_path: &Path,
2743    _program: &OsStr,
2744    _args: &[OsString],
2745    _task_marker: &Path,
2746    _marker_fds: Option<(i32, i32)>,
2747    _prepared: Option<&()>,
2748) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2749    Err("sandbox_unavailable".to_string())
2750}
2751
2752#[cfg(all(test, unix))]
2753mod tests {
2754
2755    use super::*;
2756
2757    #[test]
2758    fn host_plan_clears_inherited_environment_and_applies_snapshot() {
2759        let environment =
2760            ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
2761        let plan = SpawnPlan::Host {
2762            shell_path: PathBuf::from("/bin/sh"),
2763            environment,
2764        };
2765        let mut command = Command::new("/bin/sh");
2766        command
2767            .arg("-c")
2768            .arg("test -z \"$SHOULD_DISAPPEAR\" && printf %s \"$APPROVED\"")
2769            .env("SHOULD_DISAPPEAR", "yes");
2770        apply_sandbox_environment(&plan, &mut command, &HashMap::new());
2771        let output = command.output().unwrap();
2772        assert!(output.status.success());
2773        assert_eq!(output.stdout, b"snapshot");
2774    }
2775
2776    #[test]
2777    fn launcher_plan_clears_ambient_environment_and_applies_safe_base() {
2778        let root = tempfile::tempdir().unwrap();
2779        let project = root.path().join("project");
2780        let temp = root.path().join("temp");
2781        std::fs::create_dir_all(&project).unwrap();
2782        std::fs::create_dir_all(&temp).unwrap();
2783        let profile = SandboxProfile::build(
2784            vec![project],
2785            Vec::new(),
2786            Vec::new(),
2787            Vec::new(),
2788            Vec::new(),
2789            Vec::new(),
2790            Vec::new(),
2791            temp,
2792        )
2793        .unwrap();
2794        let expected_temp = profile.temp_dir.clone();
2795        let plan = SpawnPlan::launcher_for_test(profile, PathBuf::from("/usr/bin/true"));
2796        let request_environment = HashMap::from([
2797            ("TERM".to_string(), "aft-test-term".to_string()),
2798            ("REQUEST_SENTINEL".to_string(), "request-value".to_string()),
2799        ]);
2800        let mut command = Command::new("/usr/bin/env");
2801        command
2802            .env("LD_PRELOAD", "/untrusted/loader.so")
2803            .env("DYLD_INSERT_LIBRARIES", "/untrusted/loader.dylib")
2804            .env("BASH_ENV", "/untrusted/bash-env")
2805            .env("AWS_SECRET_ACCESS_KEY", "ambient-secret");
2806
2807        apply_sandbox_environment(&plan, &mut command, &request_environment);
2808        let output = command.output().unwrap();
2809        assert!(output.status.success());
2810        let output = String::from_utf8(output.stdout).unwrap();
2811
2812        for leaked in [
2813            "LD_PRELOAD=",
2814            "DYLD_INSERT_LIBRARIES=",
2815            "BASH_ENV=",
2816            "AWS_SECRET_ACCESS_KEY=",
2817        ] {
2818            assert!(
2819                !output.contains(leaked),
2820                "ambient variable leaked: {leaked}"
2821            );
2822        }
2823        assert!(output.contains("REQUEST_SENTINEL=request-value\n"));
2824        assert!(output.contains("TERM=aft-test-term\n"));
2825        assert!(output.contains(&format!(
2826            "PATH={}\n",
2827            crate::effective_path::effective_path().to_string_lossy()
2828        )));
2829        if let Some(home) = std::env::var_os("HOME") {
2830            assert!(output.contains(&format!("HOME={}\n", home.to_string_lossy())));
2831        }
2832        for key in ["TMPDIR", "TEMP", "TMP"] {
2833            assert!(output.contains(&format!("{key}={}\n", expected_temp.display())));
2834        }
2835    }
2836
2837    #[cfg(unix)]
2838    #[test]
2839    fn request_environment_cannot_inject_preexec_hijack_variables() {
2840        // The pre-sandbox boundary: a request-supplied environment is applied to
2841        // the OUTER launcher / `/bin/sh` supervisor, which run BEFORE Landlock or
2842        // Seatbelt is installed. A loader/shell/interpreter startup hook arriving
2843        // through the request env must be dropped, or injected code executes
2844        // outside confinement. (The sibling test covers the ambient direction;
2845        // this covers the request-supplied direction the re-audit flagged.)
2846        let project = tempfile::tempdir().unwrap();
2847        let temp = project.path().join("sandbox-temp");
2848        std::fs::create_dir(&temp).unwrap();
2849        let profile = crate::sandbox_profile::SandboxProfile::build(
2850            vec![project.path().to_path_buf()],
2851            Vec::new(),
2852            Vec::new(),
2853            Vec::new(),
2854            Vec::new(),
2855            Vec::new(),
2856            Vec::new(),
2857            temp,
2858        )
2859        .unwrap();
2860        let plan = SpawnPlan::launcher_for_test(profile, PathBuf::from("/usr/bin/true"));
2861
2862        // Every dangerous key arrives through the REQUEST environment this time.
2863        let hijacks = [
2864            ("LD_PRELOAD", "/untrusted/loader.so"),
2865            ("LD_LIBRARY_PATH", "/untrusted/lib"),
2866            ("LD_AUDIT", "/untrusted/audit.so"),
2867            ("DYLD_INSERT_LIBRARIES", "/untrusted/loader.dylib"),
2868            ("DYLD_LIBRARY_PATH", "/untrusted/dylib"),
2869            ("BASH_ENV", "/untrusted/bash-env"),
2870            ("ENV", "/untrusted/sh-env"),
2871            ("PROMPT_COMMAND", "/untrusted/cmd"),
2872            ("PS4", "evil"),
2873            ("PYTHONSTARTUP", "/untrusted/py"),
2874            ("PYTHONPATH", "/untrusted/pypath"),
2875            ("PERL5OPT", "-M/untrusted"),
2876            ("RUBYOPT", "-r/untrusted"),
2877            ("NODE_OPTIONS", "--require=/untrusted"),
2878            ("GCONV_PATH", "/untrusted/gconv"),
2879            ("LOCPATH", "/untrusted/loc"),
2880        ];
2881        let request_environment: HashMap<String, String> = hijacks
2882            .iter()
2883            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2884            .chain([("REQUEST_SENTINEL".to_string(), "kept".to_string())])
2885            .collect();
2886
2887        let mut command = Command::new("/usr/bin/env");
2888        apply_sandbox_environment(&plan, &mut command, &request_environment);
2889        let output = command.output().unwrap();
2890        assert!(output.status.success());
2891        let output = String::from_utf8(output.stdout).unwrap();
2892
2893        for (key, _) in hijacks {
2894            assert!(
2895                !output.contains(&format!("{key}=")),
2896                "request-supplied hijack var reached the pre-sandbox child: {key}\n{output}"
2897            );
2898        }
2899        // A benign request var still flows through, proving we filtered rather
2900        // than dropped the whole request environment.
2901        assert!(output.contains("REQUEST_SENTINEL=kept\n"), "{output}");
2902    }
2903
2904    #[test]
2905    fn unsandboxed_plan_preserves_inherited_and_request_environment() {
2906        let plan = SpawnPlan::Unsandboxed;
2907        let request_environment =
2908            HashMap::from([("REQUEST_SENTINEL".to_string(), "request-value".to_string())]);
2909        #[cfg(target_os = "macos")]
2910        let loader_hook = "/usr/lib/libSystem.B.dylib";
2911        #[cfg(target_os = "linux")]
2912        let loader_hook = "libc.so.6";
2913        let mut command = Command::new("/usr/bin/env");
2914        command
2915            .env("UNSANDBOXED_PARENT_SENTINEL", "raw-parent")
2916            .env("LD_PRELOAD", loader_hook)
2917            .env("DYLD_INSERT_LIBRARIES", loader_hook)
2918            .env("AWS_SECRET_ACCESS_KEY", "ambient-cloud-secret")
2919            .envs(&request_environment);
2920
2921        let before = command
2922            .get_envs()
2923            .map(|(key, value)| (key.to_os_string(), value.map(OsStr::to_os_string)))
2924            .collect::<Vec<_>>();
2925        apply_sandbox_environment(&plan, &mut command, &request_environment);
2926        let after = command
2927            .get_envs()
2928            .map(|(key, value)| (key.to_os_string(), value.map(OsStr::to_os_string)))
2929            .collect::<Vec<_>>();
2930        assert_eq!(after, before, "unsandboxed environment overrides changed");
2931        let output = command.output().unwrap();
2932        assert!(output.status.success());
2933        let output = String::from_utf8(output.stdout).unwrap();
2934        assert!(output.contains("UNSANDBOXED_PARENT_SENTINEL=raw-parent\n"));
2935        assert!(output.contains(&format!("LD_PRELOAD={loader_hook}\n")));
2936        #[cfg(target_os = "linux")]
2937        assert!(output.contains(&format!("DYLD_INSERT_LIBRARIES={loader_hook}\n")));
2938        assert!(output.contains("AWS_SECRET_ACCESS_KEY=ambient-cloud-secret\n"));
2939        assert!(output.contains("REQUEST_SENTINEL=request-value\n"));
2940    }
2941
2942    #[test]
2943    fn product_profile_is_passed_as_a_verified_buffer() {
2944        let root = tempfile::tempdir().unwrap();
2945        let project = root.path().join("project");
2946        let temp = root.path().join("temp");
2947        std::fs::create_dir_all(&project).unwrap();
2948        std::fs::create_dir_all(&temp).unwrap();
2949        let profile = SandboxProfile::build(
2950            vec![project],
2951            Vec::new(),
2952            Vec::new(),
2953            Vec::new(),
2954            Vec::new(),
2955            Vec::new(),
2956            Vec::new(),
2957            temp,
2958        )
2959        .unwrap();
2960        let (_program, args, retained) = launcher_argv(
2961            &profile,
2962            Path::new("/bin/aft"),
2963            OsStr::new("/bin/sh"),
2964            &[OsString::from("-c"), OsString::from("true")],
2965            Path::new("unused"),
2966            None,
2967            None,
2968        )
2969        .unwrap();
2970        assert_eq!(args[0], "sandbox-launch");
2971        assert_eq!(args[1], "--profile-json");
2972        assert!(serde_json::from_str::<SandboxProfile>(args[2].to_str().unwrap()).is_ok());
2973        assert!(retained.is_none());
2974        assert!(!root.path().join("sandbox-profile.json").exists());
2975    }
2976}
2977
2978#[cfg(test)]
2979mod policy_tests {
2980    use super::*;
2981
2982    fn context(project_root: PathBuf) -> AppContext {
2983        AppContext::new(
2984            Box::new(crate::parser::TreeSitterProvider::new()),
2985            crate::config::Config {
2986                project_root: Some(project_root),
2987                sandbox: crate::config::SandboxConfig {
2988                    enabled: true,
2989                    ..crate::config::SandboxConfig::default()
2990                },
2991                ..crate::config::Config::default()
2992            },
2993        )
2994    }
2995
2996    #[cfg(unix)]
2997    #[test]
2998    fn native_sandbox_predicate_controls_spawn_and_rewrite() {
2999        let project = tempfile::tempdir().unwrap();
3000        let file = project.path().join("rewrite-probe.txt");
3001        std::fs::write(&file, "sandboxed\n").unwrap();
3002        let ctx = context(project.path().to_path_buf());
3003        ctx.update_config(|config| config.experimental_bash_rewrite = true);
3004        let principal = AuthenticatedPrincipal::FirstParty;
3005        let command = format!("cat {}", file.display());
3006
3007        assert!(native_sandbox_enforced(&ctx, &principal));
3008        assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_none());
3009        let sandboxed = resolve_sandbox_spawn(
3010            &ctx,
3011            &principal,
3012            RequestedSandboxTier::Native,
3013            SandboxTaskKind::BashForeground,
3014            project.path(),
3015            None,
3016        );
3017        assert!(matches!(&sandboxed, SpawnPlan::Launcher { .. }));
3018        sandboxed.cleanup_unspawned();
3019
3020        ctx.update_config(|config| config.sandbox.enabled = false);
3021        assert!(!native_sandbox_enforced(&ctx, &principal));
3022        assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_some());
3023        assert_eq!(
3024            resolve_sandbox_spawn(
3025                &ctx,
3026                &principal,
3027                RequestedSandboxTier::Native,
3028                SandboxTaskKind::BashForeground,
3029                project.path(),
3030                None,
3031            ),
3032            SpawnPlan::Unsandboxed
3033        );
3034    }
3035
3036    #[test]
3037    fn untrusted_principal_never_enters_the_native_launcher() {
3038        let project = tempfile::tempdir().unwrap();
3039        let ctx = context(project.path().to_path_buf());
3040        let principal = AuthenticatedPrincipal::RouteBind {
3041            trust: PrincipalTrust::Untrusted,
3042            route_channel: 7,
3043            route_epoch: 1,
3044            project_root: project.path().to_path_buf(),
3045            harness: "mcp:test".to_string(),
3046            session_id: "untrusted-sandbox-test".to_string(),
3047            principal_id: Some("unverified".to_string()),
3048        };
3049
3050        let plan = resolve_sandbox_spawn(
3051            &ctx,
3052            &principal,
3053            RequestedSandboxTier::Native,
3054            SandboxTaskKind::BashForeground,
3055            project.path(),
3056            None,
3057        );
3058        // The invariant is that an untrusted principal never receives a native
3059        // Launcher plan. On Unix that surfaces as a downgrade to Unsandboxed
3060        // (the untrusted principal fails the first-party enforcement check); on
3061        // platforms without a kernel backend the enabled policy fails closed
3062        // with a platform refusal before the trust check is reached. Both honor
3063        // the invariant, so assert the exact non-Launcher outcome per platform.
3064        #[cfg(unix)]
3065        assert_eq!(plan, SpawnPlan::Unsandboxed);
3066        #[cfg(not(unix))]
3067        assert!(
3068            matches!(&plan, SpawnPlan::Refused { code, .. } if *code == "sandbox_unavailable"),
3069            "untrusted principal must never reach the native launcher; got {plan:?}"
3070        );
3071    }
3072
3073    #[cfg(unix)]
3074    fn grant_attempt(
3075        grant_id: String,
3076        project: &Path,
3077        command: &[u8],
3078        environment: ChildEnvironment,
3079    ) -> HostEscalationAttempt {
3080        HostEscalationAttempt {
3081            grant_id,
3082            command: command.to_vec(),
3083            root: project.to_path_buf(),
3084            cwd: project.to_path_buf(),
3085            shell_path: PathBuf::from("/bin/sh"),
3086            environment,
3087        }
3088    }
3089
3090    #[cfg(unix)]
3091    fn mint_test_grant(
3092        ctx: &AppContext,
3093        principal: &AuthenticatedPrincipal,
3094        project: &Path,
3095        command: &[u8],
3096        environment: &ChildEnvironment,
3097        now: Instant,
3098    ) -> String {
3099        mint_host_escalation_grant_at(
3100            ctx,
3101            principal,
3102            command,
3103            project,
3104            project,
3105            Path::new("/bin/sh"),
3106            environment,
3107            &project.join(".aft-test-storage"),
3108            "test-session",
3109            now,
3110        )
3111        .unwrap()
3112    }
3113
3114    #[cfg(unix)]
3115    fn refusal_class(plan: &SpawnPlan) -> Option<&'static str> {
3116        plan.refusal_mismatch_class()
3117    }
3118
3119    #[cfg(unix)]
3120    #[test]
3121    fn escalation_grant_binds_exact_command_and_environment() {
3122        let project = tempfile::tempdir().unwrap();
3123        let ctx = context(project.path().to_path_buf());
3124        let principal = AuthenticatedPrincipal::FirstParty;
3125        let environment = ChildEnvironment::from([
3126            (OsString::from("A"), OsString::from("one")),
3127            (OsString::from("B"), OsString::from("two")),
3128        ]);
3129
3130        for (command, retry_environment) in [
3131            (b"printf approved!".as_slice(), environment.clone()),
3132            (
3133                b"printf approved".as_slice(),
3134                ChildEnvironment::from([
3135                    (OsString::from("A"), OsString::from("changed")),
3136                    (OsString::from("B"), OsString::from("two")),
3137                ]),
3138            ),
3139        ] {
3140            let grant_id = mint_test_grant(
3141                &ctx,
3142                &principal,
3143                project.path(),
3144                b"printf approved",
3145                &environment,
3146                Instant::now(),
3147            );
3148            let attempt = grant_attempt(grant_id, project.path(), command, retry_environment);
3149            let plan = resolve_sandbox_spawn(
3150                &ctx,
3151                &principal,
3152                RequestedSandboxTier::Host,
3153                SandboxTaskKind::BashForeground,
3154                project.path(),
3155                Some(&attempt),
3156            );
3157            assert_eq!(refusal_class(&plan), Some("digest_mismatch"));
3158        }
3159    }
3160
3161    #[cfg(unix)]
3162    #[test]
3163    fn escalation_grant_is_single_use_and_expires() {
3164        let project = tempfile::tempdir().unwrap();
3165        let ctx = context(project.path().to_path_buf());
3166        let principal = AuthenticatedPrincipal::FirstParty;
3167        let environment =
3168            ChildEnvironment::from([(OsString::from("ONLY"), OsString::from("snapshot"))]);
3169        let grant_id = mint_test_grant(
3170            &ctx,
3171            &principal,
3172            project.path(),
3173            b"true",
3174            &environment,
3175            Instant::now(),
3176        );
3177        let attempt = grant_attempt(grant_id, project.path(), b"true", environment.clone());
3178        let first = resolve_sandbox_spawn(
3179            &ctx,
3180            &principal,
3181            RequestedSandboxTier::Host,
3182            SandboxTaskKind::BashForeground,
3183            project.path(),
3184            Some(&attempt),
3185        );
3186        assert!(matches!(first.policy(), SpawnPlan::Host { .. }));
3187        let second = resolve_sandbox_spawn(
3188            &ctx,
3189            &principal,
3190            RequestedSandboxTier::Host,
3191            SandboxTaskKind::BashForeground,
3192            project.path(),
3193            Some(&attempt),
3194        );
3195        assert_eq!(refusal_class(&second), Some("consumed"));
3196
3197        let expired_id = mint_test_grant(
3198            &ctx,
3199            &principal,
3200            project.path(),
3201            b"true",
3202            &environment,
3203            Instant::now() - ESCALATION_GRANT_TTL - Duration::from_millis(1),
3204        );
3205        let expired_attempt =
3206            grant_attempt(expired_id, project.path(), b"true", environment.clone());
3207        let expired = resolve_sandbox_spawn(
3208            &ctx,
3209            &principal,
3210            RequestedSandboxTier::Host,
3211            SandboxTaskKind::BashForeground,
3212            project.path(),
3213            Some(&expired_attempt),
3214        );
3215        assert_eq!(refusal_class(&expired), Some("expired"));
3216    }
3217
3218    #[cfg(unix)]
3219    #[test]
3220    fn escalation_grant_binds_principal_and_happy_path_reuses_snapshot() {
3221        let project = tempfile::tempdir().unwrap();
3222        let ctx = context(project.path().to_path_buf());
3223        let principal = AuthenticatedPrincipal::RouteBind {
3224            trust: PrincipalTrust::FirstParty,
3225            route_channel: 9,
3226            route_epoch: 2,
3227            project_root: project.path().to_path_buf(),
3228            harness: "opencode".to_string(),
3229            session_id: "session-x".to_string(),
3230            principal_id: Some("direct".to_string()),
3231        };
3232        let environment =
3233            ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
3234        let wrong_id = mint_test_grant(
3235            &ctx,
3236            &principal,
3237            project.path(),
3238            b"true",
3239            &environment,
3240            Instant::now(),
3241        );
3242        let wrong_attempt = grant_attempt(wrong_id, project.path(), b"true", environment.clone());
3243        let wrong = resolve_sandbox_spawn(
3244            &ctx,
3245            &AuthenticatedPrincipal::FirstParty,
3246            RequestedSandboxTier::Host,
3247            SandboxTaskKind::BashForeground,
3248            project.path(),
3249            Some(&wrong_attempt),
3250        );
3251        assert_eq!(refusal_class(&wrong), Some("wrong_principal"));
3252
3253        let happy_id = mint_test_grant(
3254            &ctx,
3255            &principal,
3256            project.path(),
3257            b"true",
3258            &environment,
3259            Instant::now(),
3260        );
3261        let happy_attempt = grant_attempt(happy_id, project.path(), b"true", environment.clone());
3262        let happy = resolve_sandbox_spawn(
3263            &ctx,
3264            &principal,
3265            RequestedSandboxTier::Host,
3266            SandboxTaskKind::BashForeground,
3267            project.path(),
3268            Some(&happy_attempt),
3269        );
3270        match happy.policy() {
3271            SpawnPlan::Host {
3272                shell_path,
3273                environment: actual,
3274            } => {
3275                assert_eq!(shell_path, Path::new("/bin/sh"));
3276                assert_eq!(actual, &environment);
3277            }
3278            other => panic!("expected host plan, got {other:?}"),
3279        }
3280    }
3281
3282    #[cfg(unix)]
3283    #[test]
3284    fn untrusted_host_request_refuses_without_minting_a_grant() {
3285        let project = tempfile::tempdir().unwrap();
3286        let ctx = context(project.path().to_path_buf());
3287        let principal = AuthenticatedPrincipal::RouteBind {
3288            trust: PrincipalTrust::Untrusted,
3289            route_channel: 7,
3290            route_epoch: 1,
3291            project_root: project.path().to_path_buf(),
3292            harness: "mcp:test".to_string(),
3293            session_id: "untrusted-escalation-test".to_string(),
3294            principal_id: Some("unverified".to_string()),
3295        };
3296        let plan = resolve_sandbox_spawn(
3297            &ctx,
3298            &principal,
3299            RequestedSandboxTier::Host,
3300            SandboxTaskKind::BashForeground,
3301            project.path(),
3302            None,
3303        );
3304        assert_eq!(plan.refusal_code(), Some("sandbox_escalation_denied"));
3305        assert!(ctx.escalation_grants().lock().grants.is_empty());
3306    }
3307
3308    #[cfg(unix)]
3309    fn grant_task_paths(ctx: &AppContext, grant_id: &str) -> (PathBuf, String) {
3310        let store = ctx.escalation_grants().lock();
3311        let grant = store.grants.get(grant_id).unwrap();
3312        (grant.session_dir.clone(), grant.task_id.clone())
3313    }
3314
3315    #[cfg(unix)]
3316    #[test]
3317    fn escalated_payload_path_race_is_refused_and_burns_the_grant() {
3318        use std::os::unix::fs::symlink;
3319
3320        let project = tempfile::tempdir().unwrap();
3321        let ctx = context(project.path().to_path_buf());
3322        let principal = AuthenticatedPrincipal::FirstParty;
3323        let environment = ChildEnvironment::new();
3324        let grant_id = mint_test_grant(
3325            &ctx,
3326            &principal,
3327            project.path(),
3328            b"true",
3329            &environment,
3330            Instant::now(),
3331        );
3332        let (session_dir, task_id) = grant_task_paths(&ctx, &grant_id);
3333        let command_path = session_dir
3334            .join(&task_id)
3335            .join("control")
3336            .join(crate::bash_background::persistence::COMMAND_FILE);
3337        let victim = project.path().join("victim");
3338        std::fs::write(&victim, b"victim-bytes").unwrap();
3339        std::fs::remove_file(&command_path).unwrap();
3340        symlink(&victim, &command_path).unwrap();
3341
3342        let attempt = grant_attempt(grant_id.clone(), project.path(), b"true", environment);
3343        let refused = resolve_sandbox_spawn(
3344            &ctx,
3345            &principal,
3346            RequestedSandboxTier::Host,
3347            SandboxTaskKind::BashForeground,
3348            project.path(),
3349            Some(&attempt),
3350        );
3351        assert_eq!(refusal_class(&refused), Some("digest_mismatch"));
3352        assert_eq!(std::fs::read(&victim).unwrap(), b"victim-bytes");
3353        let consumed = resolve_sandbox_spawn(
3354            &ctx,
3355            &principal,
3356            RequestedSandboxTier::Host,
3357            SandboxTaskKind::BashForeground,
3358            project.path(),
3359            Some(&attempt),
3360        );
3361        assert_eq!(refusal_class(&consumed), Some("consumed"));
3362    }
3363
3364    #[cfg(unix)]
3365    #[test]
3366    fn verified_host_payload_executes_verified_buffers_after_inode_mutation() {
3367        use std::fs::OpenOptions;
3368        use std::os::fd::AsRawFd;
3369        use std::os::unix::fs::OpenOptionsExt;
3370
3371        let project = tempfile::tempdir().unwrap();
3372        let ctx = context(project.path().to_path_buf());
3373        let principal = AuthenticatedPrincipal::FirstParty;
3374        let environment = ChildEnvironment::new();
3375        let grant_id = mint_test_grant(
3376            &ctx,
3377            &principal,
3378            project.path(),
3379            b"true",
3380            &environment,
3381            Instant::now(),
3382        );
3383        let attempt = grant_attempt(grant_id, project.path(), b"true", environment);
3384        let plan = resolve_sandbox_spawn(
3385            &ctx,
3386            &principal,
3387            RequestedSandboxTier::Host,
3388            SandboxTaskKind::BashForeground,
3389            project.path(),
3390            Some(&attempt),
3391        );
3392        let prepared = plan.prepared_task().expect("verified prepared task");
3393        let command_path = prepared
3394            .paths()
3395            .control_dir
3396            .join(crate::bash_background::persistence::COMMAND_FILE);
3397        let victim = project.path().join("victim");
3398        std::fs::write(&victim, b"victim-bytes").unwrap();
3399        let payload = prepared.invocation().unwrap();
3400        // Mutate the same inode after verification. Execution must use the
3401        // verified in-memory buffers rather than rereading the held file.
3402        std::fs::write(
3403            &command_path,
3404            format!("printf hacked > {}", victim.display()),
3405        )
3406        .unwrap();
3407        let exit_path = project.path().join("exit");
3408        let exit = OpenOptions::new()
3409            .read(true)
3410            .write(true)
3411            .create_new(true)
3412            .mode(0o600)
3413            .open(&exit_path)
3414            .unwrap();
3415        crate::bash_background::persistence::set_close_on_exec(exit.as_raw_fd(), false).unwrap();
3416        // Mirror production (apply_marker_fd_allowlist): the real marker fd is
3417        // dup2'd onto the low, single-digit CHILD_EXIT_FD before exec, and the
3418        // wrapper is handed that literal. POSIX sh (dash) only parses a
3419        // single-digit `>&N` redirect target, so passing a raw multi-digit fd
3420        // here would diverge from production and fail under dash with
3421        // "Bad fd number" (production never hits this: it always remaps to 3).
3422        let raw_exit_fd = exit.as_raw_fd();
3423        let exit_fd = CHILD_EXIT_FD.to_string();
3424        let mut command = Command::new("/bin/sh");
3425        command.args([
3426            OsStr::new("-c"),
3427            payload.wrapper_text.as_os_str(),
3428            OsStr::new("aft-payload-wrapper"),
3429            OsStr::new("/bin/sh"),
3430            payload.command_text.as_os_str(),
3431            OsStr::new(&exit_fd),
3432        ]);
3433        {
3434            use std::os::unix::process::CommandExt;
3435            unsafe {
3436                command.pre_exec(move || {
3437                    if raw_exit_fd != CHILD_EXIT_FD && libc::dup2(raw_exit_fd, CHILD_EXIT_FD) < 0 {
3438                        return Err(std::io::Error::last_os_error());
3439                    }
3440                    Ok(())
3441                });
3442            }
3443        }
3444        let status = command.status().unwrap();
3445        assert!(status.success());
3446        assert_eq!(std::fs::read(&victim).unwrap(), b"victim-bytes");
3447    }
3448
3449    #[cfg(unix)]
3450    #[test]
3451    fn approval_spawn_drift_matrix_refuses_every_bound_field() {
3452        let project = tempfile::tempdir().unwrap();
3453        let ctx = context(project.path().to_path_buf());
3454        let principal = AuthenticatedPrincipal::FirstParty;
3455        let approved = ChildEnvironment::from([(OsString::from("A"), OsString::from("one"))]);
3456        for drift in [
3457            "command",
3458            "newline",
3459            "encoding",
3460            "cwd",
3461            "root",
3462            "shell",
3463            "environment",
3464            "environment_file_encoding",
3465            "wrapper_template",
3466        ] {
3467            let grant_id = mint_test_grant(
3468                &ctx,
3469                &principal,
3470                project.path(),
3471                b"printf approved",
3472                &approved,
3473                Instant::now(),
3474            );
3475            let mut attempt = grant_attempt(
3476                grant_id,
3477                project.path(),
3478                b"printf approved",
3479                approved.clone(),
3480            );
3481            match drift {
3482                "command" => attempt.command = b"printf changed".to_vec(),
3483                "newline" => attempt.command.push(b'\n'),
3484                "encoding" => attempt.command.push(0xff),
3485                "cwd" => attempt.cwd = project.path().join("changed-cwd"),
3486                "root" => attempt.root = project.path().join("changed-root"),
3487                "shell" => attempt.shell_path = PathBuf::from("/bin/bash"),
3488                "environment" => {
3489                    attempt
3490                        .environment
3491                        .insert(OsString::from("A"), OsString::from("two"));
3492                }
3493                "environment_file_encoding" | "wrapper_template" => {
3494                    let (session_dir, task_id) = grant_task_paths(&ctx, &attempt.grant_id);
3495                    let name = if drift == "wrapper_template" {
3496                        crate::bash_background::persistence::WRAPPER_FILE
3497                    } else {
3498                        crate::bash_background::persistence::ENVIRONMENT_FILE
3499                    };
3500                    std::fs::write(
3501                        session_dir.join(task_id).join("control").join(name),
3502                        b"drift",
3503                    )
3504                    .unwrap();
3505                }
3506                _ => unreachable!(),
3507            }
3508            let refused = resolve_sandbox_spawn(
3509                &ctx,
3510                &principal,
3511                RequestedSandboxTier::Host,
3512                SandboxTaskKind::BashForeground,
3513                project.path(),
3514                Some(&attempt),
3515            );
3516            assert_eq!(refusal_class(&refused), Some("digest_mismatch"), "{drift}");
3517        }
3518    }
3519
3520    #[cfg(unix)]
3521    #[test]
3522    fn payload_read_grant_seam_exposes_only_exact_control_objects() {
3523        let storage = tempfile::tempdir().unwrap();
3524        let project = tempfile::tempdir().unwrap();
3525        let principal = AuthenticatedPrincipal::FirstParty;
3526        let environment = ChildEnvironment::from([(OsString::from("SAFE"), OsString::from("yes"))]);
3527        let layout =
3528            crate::bash_background::persistence::allocate_task_layout(storage.path(), "session")
3529                .unwrap();
3530        let task = prepare_task_payload(
3531            &layout,
3532            b"true",
3533            project.path(),
3534            project.path(),
3535            &principal,
3536            Path::new("/bin/sh"),
3537            &environment,
3538        )
3539        .unwrap();
3540        let plan = SpawnPlan::Unsandboxed.with_prepared_task(task.clone());
3541        let grants = plan.payload_read_grants();
3542        assert_eq!(grants.len(), 3);
3543        assert_eq!(grants, task.payload_read_grants());
3544        assert!(grants
3545            .iter()
3546            .all(|path| path.parent() == Some(task.paths().control_dir.as_path())));
3547        assert!(!grants.contains(&task.paths().manifest));
3548        let sandbox_temp = storage.path().join("sandbox-temp");
3549        std::fs::create_dir_all(&sandbox_temp).unwrap();
3550        let b2_profile = SandboxProfile::build(
3551            vec![project.path().to_path_buf()],
3552            Vec::new(),
3553            Vec::new(),
3554            Vec::new(),
3555            vec![task.paths().control_dir.clone()],
3556            Vec::new(),
3557            Vec::new(),
3558            sandbox_temp,
3559        )
3560        .unwrap();
3561        assert!(b2_profile
3562            .read_deny
3563            .contains(&std::fs::canonicalize(&task.paths().control_dir).unwrap()));
3564        assert!(grants.iter().all(|path| {
3565            path.parent() == Some(task.paths().control_dir.as_path())
3566                && path != &task.paths().manifest
3567        }));
3568
3569        #[cfg(target_os = "linux")]
3570        {
3571            let refused = SpawnPlan::launcher_for_test(b2_profile, PathBuf::from("/usr/bin/false"))
3572                .with_prepared_task(task.clone());
3573            assert_eq!(refused.refusal_code(), Some("sandbox_unavailable"));
3574            assert!(refused
3575                .refusal_message()
3576                .is_some_and(|message| message.contains("mandatory read root")));
3577
3578            let allowed_temp = storage.path().join("allowed-sandbox-temp");
3579            std::fs::create_dir_all(&allowed_temp).unwrap();
3580            let allowed_profile = SandboxProfile::build(
3581                vec![project.path().to_path_buf()],
3582                Vec::new(),
3583                Vec::new(),
3584                vec![project.path().to_path_buf()],
3585                Vec::new(),
3586                Vec::new(),
3587                Vec::new(),
3588                allowed_temp,
3589            )
3590            .unwrap();
3591            let prepared =
3592                SpawnPlan::launcher_for_test(allowed_profile, PathBuf::from("/usr/bin/true"))
3593                    .with_prepared_task(task.clone());
3594            let profile = match prepared {
3595                SpawnPlan::Prepared { plan, .. } => match *plan {
3596                    SpawnPlan::Launcher { profile, .. } => profile,
3597                    other => panic!("expected launcher plan, got {other:?}"),
3598                },
3599                other => panic!("expected prepared plan, got {other:?}"),
3600            };
3601            let canonical_payloads = grants
3602                .iter()
3603                .map(|path| path.canonicalize().unwrap())
3604                .collect::<Vec<_>>();
3605            assert!(canonical_payloads
3606                .iter()
3607                .all(|path| profile.read_allow.contains(path)));
3608            assert!(profile.read_allow.iter().all(|path| {
3609                !path.starts_with(&task.paths().control_dir) || canonical_payloads.contains(path)
3610            }));
3611            validate_final_read_rules(&profile.read_allow, &profile.read_deny).unwrap();
3612        }
3613
3614        assert_eq!(task.environment(), &environment);
3615    }
3616
3617    #[cfg(unix)]
3618    #[test]
3619    fn writable_roots_refuse_both_session_store_overlap_directions() {
3620        fn profile(base: &Path, write_root: PathBuf) -> SandboxProfile {
3621            let project = base.join("project");
3622            let home = base.join("home");
3623            let temp = base.join("temp");
3624            for path in [&project, &home, &temp, &write_root] {
3625                std::fs::create_dir_all(path).unwrap();
3626            }
3627            SandboxProfile::build(
3628                vec![project, write_root],
3629                Vec::new(),
3630                Vec::new(),
3631                Vec::new(),
3632                Vec::new(),
3633                Vec::new(),
3634                Vec::new(),
3635                temp,
3636            )
3637            .unwrap()
3638        }
3639
3640        let base = tempfile::tempdir().unwrap();
3641        let session = base.path().join("store/session");
3642        let io = session.join("bash-0000000000000001/io");
3643        let control = session.join("bash-0000000000000001/control");
3644        std::fs::create_dir_all(&io).unwrap();
3645        std::fs::create_dir_all(&control).unwrap();
3646        let canonical_session = std::fs::canonicalize(&session).unwrap();
3647        let canonical_io = std::fs::canonicalize(&io).unwrap();
3648
3649        let ancestor = profile(base.path(), base.path().join("store"));
3650        assert!(refuse_store_overlap(&ancestor, &canonical_session, &canonical_io).is_err());
3651        let descendant = profile(base.path(), control);
3652        assert!(refuse_store_overlap(&descendant, &canonical_session, &canonical_io).is_err());
3653        let allowed = profile(base.path(), io);
3654        assert!(refuse_store_overlap(&allowed, &canonical_session, &canonical_io).is_ok());
3655    }
3656
3657    #[cfg(windows)]
3658    #[test]
3659    fn enabled_host_request_is_refused_on_windows_without_a_grant() {
3660        let project = tempfile::tempdir().unwrap();
3661        let ctx = context(project.path().to_path_buf());
3662        let plan = resolve_sandbox_spawn(
3663            &ctx,
3664            &AuthenticatedPrincipal::FirstParty,
3665            RequestedSandboxTier::Host,
3666            SandboxTaskKind::BashForeground,
3667            project.path(),
3668            None,
3669        );
3670        assert_eq!(plan.refusal_code(), Some("sandbox_unavailable"));
3671        assert_eq!(
3672            plan.refusal_message(),
3673            Some(
3674                "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
3675            )
3676        );
3677    }
3678
3679    #[cfg(windows)]
3680    #[test]
3681    fn enabled_native_tier_is_refused_on_windows() {
3682        let project = tempfile::tempdir().unwrap();
3683        let ctx = context(project.path().to_path_buf());
3684        let plan = resolve_sandbox_spawn(
3685            &ctx,
3686            &AuthenticatedPrincipal::FirstParty,
3687            RequestedSandboxTier::Native,
3688            SandboxTaskKind::BashForeground,
3689            project.path(),
3690            None,
3691        );
3692        assert_eq!(plan.refusal_code(), Some("sandbox_unavailable"));
3693        assert_eq!(
3694            plan.refusal_message(),
3695            Some(
3696                "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
3697            )
3698        );
3699    }
3700}
3701
3702#[cfg(test)]
3703mod read_allow_tests {
3704    use super::*;
3705
3706    #[derive(Default)]
3707    struct FakeLister {
3708        entries: BTreeMap<PathBuf, Result<Vec<ListedReadChild>, String>>,
3709    }
3710
3711    impl FakeLister {
3712        fn directory(mut self, parent: &str, children: &[(&str, bool)]) -> Self {
3713            let parent = PathBuf::from(parent);
3714            self.entries.insert(
3715                parent.clone(),
3716                Ok(children
3717                    .iter()
3718                    .map(|(name, is_dir)| ListedReadChild {
3719                        path: parent.join(name),
3720                        is_dir: *is_dir,
3721                    })
3722                    .collect()),
3723            );
3724            self
3725        }
3726
3727        fn failure(mut self, parent: &str, message: &str) -> Self {
3728            self.entries
3729                .insert(PathBuf::from(parent), Err(message.to_string()));
3730            self
3731        }
3732    }
3733
3734    impl ReadDirectoryLister for FakeLister {
3735        fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String> {
3736            self.entries
3737                .remove(parent)
3738                .unwrap_or_else(|| Err(format!("unexpected enumeration of {}", parent.display())))
3739        }
3740    }
3741
3742    fn grant(path: &str, force_children: bool, mandatory: bool) -> IntendedReadGrant {
3743        IntendedReadGrant {
3744            path: PathBuf::from(path),
3745            force_children,
3746            mandatory,
3747        }
3748    }
3749
3750    #[test]
3751    fn read_grants_split_home_across_all_deny_chains() {
3752        let mut lister = FakeLister::default()
3753            .directory(
3754                "/home/alice",
3755                &[
3756                    (".ssh", true),
3757                    (".config", true),
3758                    ("work", true),
3759                    ("notes", false),
3760                ],
3761            )
3762            .directory(
3763                "/home/alice/.config",
3764                &[("gcloud", true), ("cortexkit", true), ("editor", true)],
3765            )
3766            .directory("/home/alice/work", &[("private", true), ("src", true)]);
3767        let denies = [
3768            "/home/alice/.ssh",
3769            "/home/alice/.config/gcloud",
3770            "/home/alice/.config/cortexkit",
3771            "/home/alice/work/private",
3772        ]
3773        .map(PathBuf::from);
3774
3775        let emitted = split_read_grants(&[grant("/home/alice", true, false)], &denies, &mut lister)
3776            .expect("split HOME grants");
3777
3778        assert_eq!(
3779            emitted,
3780            [
3781                "/home/alice/.config/editor",
3782                "/home/alice/notes",
3783                "/home/alice/work/src",
3784            ]
3785            .map(PathBuf::from)
3786        );
3787    }
3788
3789    #[test]
3790    fn secure_enumeration_omits_home_child_symlinks() {
3791        let mut lister = FakeLister::default()
3792            .directory("/home/alice", &[("ordinary", true), ("plain-file", false)]);
3793        let emitted = split_read_grants(
3794            &[grant("/home/alice", true, false)],
3795            &[PathBuf::from("/home/alice/.ssh")],
3796            &mut lister,
3797        )
3798        .expect("split HOME grants");
3799
3800        assert_eq!(
3801            emitted,
3802            ["/home/alice/ordinary", "/home/alice/plain-file"].map(PathBuf::from)
3803        );
3804        assert!(!emitted.iter().any(|path| path.ends_with("secret-link")));
3805    }
3806
3807    #[test]
3808    fn enumeration_race_refuses_instead_of_weakening_the_floor() {
3809        let mut lister = FakeLister::default().failure("/home/alice", "entry disappeared");
3810        let error = split_read_grants(
3811            &[grant("/home/alice", true, false)],
3812            &[PathBuf::from("/home/alice/.ssh")],
3813            &mut lister,
3814        )
3815        .expect_err("racing enumeration must fail closed");
3816
3817        assert!(error.contains("cannot split read root /home/alice"));
3818        assert!(error.contains("entry disappeared"));
3819    }
3820
3821    #[test]
3822    fn mandatory_floor_rejects_equal_containing_and_nested_writable_roots() {
3823        let floor = vec![PathBuf::from("/home/alice/.ssh")];
3824        for writable in [
3825            Path::new("/home/alice/.ssh"),
3826            Path::new("/home/alice"),
3827            Path::new("/home/alice/.ssh/cache"),
3828        ] {
3829            let error = validate_mandatory_floor_overlap([writable], &floor)
3830                .expect_err("mandatory floor overlap must refuse");
3831            assert!(error.contains("overlaps mandatory secret floor"));
3832        }
3833        validate_mandatory_floor_overlap([Path::new("/home/alice/project")], &floor)
3834            .expect("disjoint writable root");
3835    }
3836
3837    #[test]
3838    fn ordinary_read_deny_under_writable_root_is_split_not_refused() {
3839        let mut lister =
3840            FakeLister::default().directory("/project", &[("private", true), ("src", true)]);
3841        let writable_root = PathBuf::from("/project");
3842        let emitted = split_read_grants(
3843            &[IntendedReadGrant {
3844                path: writable_root.clone(),
3845                force_children: false,
3846                mandatory: false,
3847            }],
3848            &[PathBuf::from("/project/private")],
3849            &mut lister,
3850        )
3851        .expect("ordinary deny should be expressible");
3852
3853        assert_eq!(emitted, vec![PathBuf::from("/project/src")]);
3854        assert_eq!(writable_root, PathBuf::from("/project"));
3855    }
3856
3857    #[test]
3858    fn static_var_grant_splits_when_home_is_beneath_it() {
3859        let mut lister = FakeLister::default()
3860            .directory("/var", &[("home", true), ("log", true)])
3861            .directory("/var/home", &[("alice", true)])
3862            .directory("/var/home/alice", &[(".ssh", true), ("work", true)]);
3863        let emitted = split_read_grants(
3864            &[grant("/var", true, true)],
3865            &[PathBuf::from("/var/home/alice/.ssh")],
3866            &mut lister,
3867        )
3868        .expect("split /var around HOME floor");
3869
3870        assert_eq!(
3871            emitted,
3872            ["/var/home/alice/work", "/var/log"].map(PathBuf::from)
3873        );
3874    }
3875
3876    #[test]
3877    fn run_sensitive_directories_are_removed_by_canonical_deny_chain() {
3878        let mut lister = FakeLister::default().directory(
3879            "/run",
3880            &[
3881                ("lock", true),
3882                ("user", true),
3883                ("credentials", true),
3884                ("secrets", true),
3885            ],
3886        );
3887        let emitted = split_read_grants(
3888            &[grant("/run", false, true)],
3889            &[
3890                PathBuf::from("/run/user"),
3891                PathBuf::from("/run/credentials"),
3892                PathBuf::from("/run/secrets"),
3893            ],
3894            &mut lister,
3895        )
3896        .expect("split /run");
3897
3898        assert_eq!(emitted, vec![PathBuf::from("/run/lock")]);
3899        assert!(!emitted.iter().any(|path| {
3900            path == Path::new("/run/credentials") || path == Path::new("/run/secrets")
3901        }));
3902    }
3903
3904    #[test]
3905    fn final_validation_rejects_every_overlap_direction() {
3906        let deny = vec![PathBuf::from("/home/alice/.ssh")];
3907        for grant in [
3908            PathBuf::from("/home/alice"),
3909            PathBuf::from("/home/alice/.ssh"),
3910            PathBuf::from("/home/alice/.ssh/key"),
3911        ] {
3912            assert!(validate_final_read_rules(&[grant], &deny).is_err());
3913        }
3914        validate_final_read_rules(&[PathBuf::from("/home/alice/work")], &deny)
3915            .expect("disjoint final grant");
3916    }
3917
3918    #[test]
3919    fn grant_beneath_ordinary_deny_is_dropped_but_mandatory_grant_refuses() {
3920        let deny = vec![PathBuf::from("/restricted")];
3921        let mut lister = FakeLister::default();
3922        let emitted = split_read_grants(
3923            &[grant("/restricted/project", false, false)],
3924            &deny,
3925            &mut lister,
3926        )
3927        .expect("ordinary grant is optional");
3928        assert!(emitted.is_empty());
3929
3930        let error = split_read_grants(
3931            &[grant("/restricted/system", false, true)],
3932            &deny,
3933            &mut lister,
3934        )
3935        .expect_err("mandatory grant under deny must refuse");
3936        assert!(error.contains("mandatory read root"));
3937    }
3938
3939    #[cfg(unix)]
3940    #[test]
3941    fn linked_worktree_resolves_common_git_dir_and_shared_hooks() {
3942        let fixture = tempfile::tempdir().expect("fixture");
3943        let main = fixture.path().join("main");
3944        let worktree = fixture.path().join("linked");
3945        std::fs::create_dir(&main).expect("main repository");
3946        assert!(Command::new("git")
3947            .args(["init", "-q"])
3948            .current_dir(&main)
3949            .status()
3950            .expect("git init")
3951            .success());
3952        std::fs::write(main.join("tracked"), b"tracked").expect("tracked file");
3953        assert!(Command::new("git")
3954            .args(["add", "tracked"])
3955            .current_dir(&main)
3956            .status()
3957            .expect("git add")
3958            .success());
3959        assert!(Command::new("git")
3960            .args([
3961                "-c",
3962                "user.name=AFT Test",
3963                "-c",
3964                "user.email=aft@example.invalid",
3965                "commit",
3966                "-qm",
3967                "initial",
3968            ])
3969            .current_dir(&main)
3970            .status()
3971            .expect("git commit")
3972            .success());
3973        assert!(Command::new("git")
3974            .args(["worktree", "add", "-q"])
3975            .arg(&worktree)
3976            .arg("HEAD")
3977            .current_dir(&main)
3978            .status()
3979            .expect("git worktree add")
3980            .success());
3981
3982        let policy = resolve_git_policy(&worktree).expect("resolve linked worktree policy");
3983        let common = main.join(".git").canonicalize().expect("common git dir");
3984        assert_eq!(policy.hooks, vec![common.join("hooks")]);
3985        #[cfg(target_os = "linux")]
3986        assert!(policy.read_roots.contains(&common));
3987    }
3988
3989    #[cfg(unix)]
3990    #[test]
3991    fn configured_hooks_path_is_resolved_to_its_effective_location() {
3992        let fixture = tempfile::tempdir().expect("fixture");
3993        let project = fixture.path().join("project");
3994        std::fs::create_dir(&project).expect("project");
3995        assert!(Command::new("git")
3996            .args(["init", "-q"])
3997            .current_dir(&project)
3998            .status()
3999            .expect("git init")
4000            .success());
4001        assert!(Command::new("git")
4002            .args(["config", "core.hooksPath", "custom-hooks"])
4003            .current_dir(&project)
4004            .status()
4005            .expect("git config")
4006            .success());
4007
4008        let policy = resolve_git_policy(&project).expect("resolve configured hooks path");
4009        assert_eq!(
4010            policy.hooks,
4011            vec![project
4012                .canonicalize()
4013                .expect("canonical project")
4014                .join("custom-hooks")]
4015        );
4016    }
4017}