Skip to main content

aft/
sandbox_spawn.rs

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